Skip to content

Add article summarization and PDF/HTML file upload support - #135

Open
jmaddington wants to merge 4 commits into
mainfrom
feature/article-summaries
Open

Add article summarization and PDF/HTML file upload support#135
jmaddington wants to merge 4 commits into
mainfrom
feature/article-summaries

Conversation

@jmaddington

@jmaddington jmaddington commented Jun 6, 2025

Copy link
Copy Markdown
Collaborator

Summary

This PR introduces two major enhancements to the article processing pipeline:

1. Article Summarization

  • Add comprehensive article summarization functionality to the processing pipeline
  • Generate concise 2-3 sentence summaries for all articles using GPT-4.1
  • Automatically include summaries in RSS feed descriptions

2. PDF/HTML File Upload Support

  • Add comprehensive file upload support for PDF, HTML, and text files in article creation
  • Integrate GPT-4.1 for intelligent content extraction and preprocessing
  • Implement automatic title generation from content or filename
  • Add extensive test coverage for file processing functionality

Key Features

Article Summarization

  • ContentAnalysisService Enhancement: Updated to generate and validate article summaries
  • Pipeline Integration: Summary generation integrated into existing article processing workflow
  • RSS Feed Enhancement: Summaries automatically included in feed item descriptions
  • Comprehensive Testing: Unit tests for summary extraction, validation, and error handling

File Upload Support

  • Multi-format Support: Users can now upload PDF, HTML, or text files instead of just URLs or text input
  • GPT-4.1 Integration: Intelligent file content cleaning and structuring for optimal audio narration
  • Smart Title Generation: Automatic title extraction from content or filename when not provided
  • Comprehensive Validation: File size limits (50MB), type validation, and form constraints
  • Fallback Mechanisms: Graceful degradation when GPT processing fails or is disabled

Technical Implementation

Database Changes

  • Enhanced Article model to persist summaries from content analysis
  • Added uploaded_file and file_type fields to Article model for file upload support

Services

  • ContentAnalysisService: Enhanced to generate and return article summaries
  • FileProcessingService: New service for file handling and GPT preprocessing

Forms & Views

  • Enhanced ArticleSubmissionForm with file upload validation and single-input enforcement
  • Updated FeedArticleCreateView to handle file uploads seamlessly

Pipeline Integration

Both features integrate seamlessly with the existing article processing pipeline:

  1. Input: URL/Text/File Upload → 2. Content Extraction → 3. GPT Analysis & Summarization → 4. TTS Generation → 5. RSS Feed with Summary

Test Coverage

  • Summary extraction and validation tests
  • File type detection and validation tests
  • Text extraction from various formats (PDF, HTML, TXT)
  • GPT-4.1 processing with comprehensive error handling
  • Form validation and edge cases
  • End-to-end integration testing
  • Error scenarios and fallback behavior

Documentation

  • Added docs/pipeline_overview.md documenting the complete article processing pipeline
  • Comprehensive inline documentation for new services and functionality

🤖 Generated with Claude Code

This feature introduces article summarization into the processing pipeline.

Key changes:
- The `ContentAnalysisService` has been updated to instruct the LLM
  to generate a 2-3 sentence summary of the article content. This
  summary is then parsed from the LLM's response.
- The `process_article` Celery task now retrieves this summary and
  saves it to the `summary` field of the `Article` model.
- The RSS feed generation (`UserFeed` in `feeds.py`) already utilized
  the `article.summary` field, so generated summaries will now
  automatically be included in the feed item descriptions.
- Added `docs/pipeline_overview.md` to document the end-to-end
  article processing pipeline, including the new summarization step.
- Added unit tests for `ContentAnalysisService` to verify summary
  extraction.
- Updated tests for the `process_article` task in `test_tasks.py`
  to ensure summaries are correctly saved and handled, including
  a new focused test for summary saving and fixes to existing
  complex tests like `test_content_analysis_called_once_for_auto_feed`.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds article summarization functionality to the content analysis pipeline so that generated summaries are saved with articles and included in the RSS feed items.

  • Updated process_article task to capture and store summaries from LLM responses.
  • Modified ContentAnalysisService to validate, extract, and return the new summary field.
  • Added tests for summary extraction and updated documentation to reflect the summarization step.

Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

File Description
text_to_audio/tasks.py Updates to process_article task to set and clear the article summary appropriately, including combined chunk analyses.
text_to_audio/services/content_analysis.py Added summary validation, extraction, and updated prompt instructions for summarization.
tests/text_to_audio/test_content_analysis_service.py New tests to ensure correct summary extraction and fallback behavior.
docs/pipeline_overview.md Documentation update reflecting the new summarization step in the article processing pipeline.

Comment thread text_to_audio/tasks.py Outdated
Comment on lines +519 to +523
combined_analysis = _combine_chunk_analyses(chunk_analyses) # This helper needs to handle summary
article.multi_voice_data = combined_analysis
# Assuming summary from the first chunk or a combined summary is handled by _combine_chunk_analyses
# If not, this needs to be set explicitly e.g. article.summary = chunk_analyses[0].get("summary", "") if chunk_analyses else ""
article.summary = combined_analysis.get("summary", "") # _combine_chunk_analyses should add this

Copilot AI Jun 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The summary is obtained solely from the first chunk analysis. Consider documenting or implementing a more robust strategy to combine summaries from multiple chunks if needed.

Suggested change
combined_analysis = _combine_chunk_analyses(chunk_analyses) # This helper needs to handle summary
article.multi_voice_data = combined_analysis
# Assuming summary from the first chunk or a combined summary is handled by _combine_chunk_analyses
# If not, this needs to be set explicitly e.g. article.summary = chunk_analyses[0].get("summary", "") if chunk_analyses else ""
article.summary = combined_analysis.get("summary", "") # _combine_chunk_analyses should add this
combined_analysis = _combine_chunk_analyses(chunk_analyses) # Ensure this aggregates summaries from all chunks
article.multi_voice_data = combined_analysis
# Explicitly set article.summary using combined summary or fallback to the first chunk's summary
article.summary = combined_analysis.get("summary", "") if "summary" in combined_analysis else (
chunk_analyses[0].get("summary", "") if chunk_analyses else ""
)

Copilot uses AI. Check for mistakes.

# Validate the structure
if "voices" not in result or "audio_segments" not in result:
if "voices" not in result or "audio_segments" not in result or "summary" not in result:

Copilot AI Jun 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation now requires a summary in the LLM response. If the summarization is non-critical, you might consider defaulting missing summaries in the LLM response instead of immediately raising an error.

Copilot uses AI. Check for mistakes.
@qodo-code-review

qodo-code-review Bot commented Jun 6, 2025

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 30b2ab1)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Missing Import

The test module configures MEDIA_ROOT using Path but does not import Path from pathlib, leading to a NameError.

django_settings.configure(
    INSTALLED_APPS=[
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'text_to_audio', # Add the app itself
    ],
    AUTH_USER_MODEL='auth.User', # Standard Django user model
    MEDIA_ROOT=Path(__file__).parent / "test_media_tasks_global", # Consistent test media root
    OPENAI_API_KEY="test_api_key_global",
Settings Configuration

The module-level call to django_settings.configure may conflict with Django’s normal settings initialization in tests and affect other test modules.

# Configure Django settings before importing models and tasks
if not django_settings.configured:
    django_settings.configure(
        INSTALLED_APPS=[
            'django.contrib.auth',
            'django.contrib.contenttypes',
            'text_to_audio', # Add the app itself
        ],
        AUTH_USER_MODEL='auth.User', # Standard Django user model
        MEDIA_ROOT=Path(__file__).parent / "test_media_tasks_global", # Consistent test media root
        OPENAI_API_KEY="test_api_key_global",
        MAX_ANALYSIS_WORDS=8000,
        OPENAI_ANALYSIS_MODEL="gpt-4.1",
        OPENAI_TTS_MODEL="tts-1-hd", # Default model that supports instructions
        LOG_OPENAI_API_CALLS=False,
        ARTICLE_PROCESSING_TIMEOUT_SECONDS=3600,
        ENABLE_CHUNK_TONE_LLM=False,
        OPENAI_TITLE_MODEL="gpt-4o-mini",
        OPENAI_TTS_VOICE="alloy", # Default voice for fallback
        DATABASES={
            'default': {
                'ENGINE': 'django.db.backends.sqlite3',
                'NAME': ':memory:',
            }
        }
    )

@qodo-code-review

qodo-code-review Bot commented Jun 6, 2025

Copy link
Copy Markdown
Contributor

CI Feedback 🧐

(Feedback updated until commit 30b2ab1)

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: type-check

Failed stage: Run mypy type checking [❌]

Failure summary:

The action failed due to multiple type checking errors detected by a static type checker (likely
mypy). The main issues include:

1. Multiple instances of "Article" class missing an "id" attribute (seen in
text_to_audio/models.py:421, 430 and many test files)
2. Multiple instances of
"Manager[AbstractBaseUser]" missing a "create_user" attribute in test files
3. Type errors in
text_to_audio/utils.py related to string operations and API calls
4. Incompatible type assignments
and attribute access issues throughout the codebase

These type errors suggest there might be changes to model definitions or interfaces that weren't
properly updated across the codebase.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

447:  shell: /usr/bin/bash -e {0}
448:  env:
449:  pythonLocation: /opt/hostedtoolcache/Python/3.11.12/x64
450:  PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.11.12/x64/lib/pkgconfig
451:  Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.11.12/x64
452:  Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.11.12/x64
453:  Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.11.12/x64
454:  LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.11.12/x64/lib
455:  DJANGO_DEBUG: True
456:  DJANGO_SECRET_KEY: ci-test-key-not-for-production
457:  SQLITE_DATA_DIR: ./data
458:  CELERY_BROKER_URL: memory://
459:  CELERY_TASK_ALWAYS_EAGER: True
460:  PYTHONPATH: .
461:  ##[endgroup]
462:  text_to_audio/models.py:421: error: "Article" has no attribute "id"  [attr-defined]
463:  text_to_audio/models.py:430: error: "Article" has no attribute "id"  [attr-defined]
464:  text_to_audio/management/commands/migrate_audio_paths.py:128: error: "Article" has no attribute "id"  [attr-defined]
465:  text_to_audio/management/commands/migrate_audio_paths.py:175: error: "Article" has no attribute "id"  [attr-defined]
466:  text_to_audio/management/commands/migrate_audio_paths.py:189: error: "Article" has no attribute "id"  [attr-defined]
467:  text_to_audio/management/commands/migrate_audio_paths.py:197: error: "Article" has no attribute "id"  [attr-defined]
468:  text_to_audio/management/commands/migrate_audio_paths.py:224: error: "Article" has no attribute "id"  [attr-defined]
469:  text_to_audio/management/commands/migrate_audio_paths.py:235: error: "Article" has no attribute "id"  [attr-defined]
470:  text_to_audio/management/commands/migrate_audio_paths.py:238: error: "Article" has no attribute "id"  [attr-defined]
471:  text_to_audio/management/commands/migrate_audio_paths.py:241: error: "Article" has no attribute "id"  [attr-defined]
472:  tests/test_voice_field_validation.py:24: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
473:  tests/test_voice_field_validation.py:198: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
474:  tests/test_voice_field_validation.py:277: error: Item "ForeignObjectRel" of "Field[Any, Any] | ForeignObjectRel | GenericForeignKey" has no attribute "help_text"  [union-attr]
475:  tests/test_voice_field_validation.py:277: error: Item "GenericForeignKey" of "Field[Any, Any] | ForeignObjectRel | GenericForeignKey" has no attribute "help_text"  [union-attr]
476:  tests/test_voice_field_validation.py:278: error: Item "ForeignObjectRel" of "Field[Any, Any] | ForeignObjectRel | GenericForeignKey" has no attribute "help_text"  [union-attr]
477:  tests/test_voice_field_validation.py:278: error: Item "GenericForeignKey" of "Field[Any, Any] | ForeignObjectRel | GenericForeignKey" has no attribute "help_text"  [union-attr]
478:  tests/test_voice_field_validation.py:289: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
479:  tests/test_models.py:187: error: "Article" has no attribute "id"  [attr-defined]
480:  tests/test_migrate_audio_paths.py:28: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
481:  tests/test_migrate_audio_paths.py:31: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
482:  tests/test_migrate_audio_paths.py:53: error: "Feed" has no attribute "id"  [attr-defined]
483:  tests/test_migrate_audio_paths.py:99: error: "Article" has no attribute "id"  [attr-defined]
484:  tests/test_migrate_audio_paths.py:100: error: "Article" has no attribute "id"  [attr-defined]
485:  tests/test_migrate_audio_paths.py:102: error: "Article" has no attribute "id"  [attr-defined]
486:  tests/test_migrate_audio_paths.py:172: error: "Article" has no attribute "id"  [attr-defined]
487:  tests/test_migrate_audio_paths.py:202: error: "Article" has no attribute "id"  [attr-defined]
488:  tests/test_migrate_audio_paths.py:264: error: "Article" has no attribute "id"  [attr-defined]
489:  tests/test_migrate_audio_paths.py:367: error: "Article" has no attribute "id"  [attr-defined]
490:  tests/test_cost_tracking.py:85: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
491:  text_to_audio/utils.py:353: error: Incompatible types in assignment (expression has type "PageElement | Tag | NavigableString", variable has type "str")  [assignment]
492:  text_to_audio/utils.py:356: error: "str" has no attribute "name"  [attr-defined]
493:  text_to_audio/utils.py:356: error: "str" has no attribute "get"  [attr-defined]
494:  text_to_audio/utils.py:357: error: Invalid index type "str" for "str"; expected type "SupportsIndex | slice[Any, Any, Any]"  [index]
495:  text_to_audio/utils.py:358: error: "str" has no attribute "name"  [attr-defined]
496:  text_to_audio/utils.py:359: error: "str" has no attribute "get"  [attr-defined]
497:  text_to_audio/utils.py:360: error: Invalid index type "str" for "str"; expected type "SupportsIndex | slice[Any, Any, Any]"  [index]
498:  text_to_audio/utils.py:361: error: "str" has no attribute "get"  [attr-defined]
499:  text_to_audio/utils.py:362: error: Invalid index type "str" for "str"; expected type "SupportsIndex | slice[Any, Any, Any]"  [index]
500:  text_to_audio/utils.py:365: error: "str" has no attribute "attrs"  [attr-defined]
501:  text_to_audio/utils.py:444: error: No overload variant of "create" of "Completions" matches argument type "dict[str, object]"  [call-overload]
502:  text_to_audio/utils.py:444: note: Possible overload variants:
503:  text_to_audio/utils.py:444: note:     def create(self, *, messages: Iterable[ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam | ChatCompletionUserMessageParam | ChatCompletionAssistantMessageParam | ChatCompletionToolMessageParam | ChatCompletionFunctionMessageParam], model: str | Literal['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4.1-2025-04-14', 'gpt-4.1-mini-2025-04-14', 'gpt-4.1-nano-2025-04-14', 'o4-mini', 'o4-mini-2025-04-16', 'o3', 'o3-2025-04-16', 'o3-mini', 'o3-mini-2025-01-31', 'o1', 'o1-2024-12-17', 'o1-preview', 'o1-preview-2024-09-12', 'o1-mini', 'o1-mini-2024-09-12', 'gpt-4o', 'gpt-4o-2024-11-20', 'gpt-4o-2024-08-06', 'gpt-4o-2024-05-13', 'gpt-4o-audio-preview', 'gpt-4o-audio-preview-2024-10-01', 'gpt-4o-audio-preview-2024-12-17', 'gpt-4o-audio-preview-2025-06-03', 'gpt-4o-mini-audio-preview', 'gpt-4o-mini-audio-preview-2024-12-17', 'gpt-4o-search-preview', 'gpt-4o-mini-search-preview', 'gpt-4o-search-preview-2025-03-11', 'gpt-4o-mini-search-preview-2025-03-11', 'chatgpt-4o-latest', 'codex-mini-latest', 'gpt-4o-mini', 'gpt-4o-mini-2024-07-18', 'gpt-4-turbo', 'gpt-4-turbo-2024-04-09', 'gpt-4-0125-preview', 'gpt-4-turbo-preview', 'gpt-4-1106-preview', 'gpt-4-vision-preview', 'gpt-4', 'gpt-4-0314', 'gpt-4-0613', 'gpt-4-32k', 'gpt-4-32k-0314', 'gpt-4-32k-0613', 'gpt-3.5-turbo', 'gpt-3.5-turbo-16k', 'gpt-3.5-turbo-0301', 'gpt-3.5-turbo-0613', 'gpt-3.5-turbo-1106', 'gpt-3.5-turbo-0125', 'gpt-3.5-turbo-16k-0613'], audio: ChatCompletionAudioParam | NotGiven | None = ..., frequency_penalty: float | NotGiven | None = ..., function_call: Literal['none', 'auto'] | ChatCompletionFunctionCallOptionParam | NotGiven = ..., functions: Iterable[Function] | NotGiven = ..., logit_bias: dict[str, int] | NotGiven | None = ..., logprobs: bool | NotGiven | None = ..., max_completion_tokens: int | NotGiven | None = ..., max_tokens: int | NotGiven | None = ..., metadata: dict[str, str] | NotGiven | None = ..., modalities: list[Literal['text', 'audio']] | NotGiven | None = ..., n: int | NotGiven | None = ..., parallel_tool_calls: bool | NotGiven = ..., prediction: ChatCompletionPredictionContentParam | NotGiven | None = ..., presence_penalty: float | NotGiven | None = ..., reasoning_effort: Literal['low', 'medium', 'high'] | None | NotGiven | None = ..., response_format: ResponseFormatText | ResponseFormatJSONSchema | ResponseFormatJSONObject | NotGiven = ..., seed: int | NotGiven | None = ..., service_tier: Literal['auto', 'default', 'flex'] | NotGiven | None = ..., stop: str | list[str] | NotGiven | None = ..., store: bool | NotGiven | None = ..., stream: Literal[False] | NotGiven | None = ..., stream_options: ChatCompletionStreamOptionsParam | NotGiven | None = ..., temperature: float | NotGiven | None = ..., tool_choice: Literal['none', 'auto', 'required'] | ChatCompletionNamedToolChoiceParam | NotGiven = ..., tools: Iterable[ChatCompletionToolParam] | NotGiven = ..., top_logprobs: int | NotGiven | None = ..., top_p: float | NotGiven | None = ..., user: str | NotGiven = ..., web_search_options: WebSearchOptions | NotGiven = ..., extra_headers: Mapping[str, str | Omit] | None = ..., extra_query: Mapping[str, object] | None = ..., extra_body: object | None = ..., timeout: float | Timeout | NotGiven | None = ...) -> ChatCompletion
504:  text_to_audio/utils.py:444: note:     def create(self, *, messages: Iterable[ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam | ChatCompletionUserMessageParam | ChatCompletionAssistantMessageParam | ChatCompletionToolMessageParam | ChatCompletionFunctionMessageParam], model: str | Literal['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4.1-2025-04-14', 'gpt-4.1-mini-2025-04-14', 'gpt-4.1-nano-2025-04-14', 'o4-mini', 'o4-mini-2025-04-16', 'o3', 'o3-2025-04-16', 'o3-mini', 'o3-mini-2025-01-31', 'o1', 'o1-2024-12-17', 'o1-preview', 'o1-preview-2024-09-12', 'o1-mini', 'o1-mini-2024-09-12', 'gpt-4o', 'gpt-4o-2024-11-20', 'gpt-4o-2024-08-06', 'gpt-4o-2024-05-13', 'gpt-4o-audio-preview', 'gpt-4o-audio-preview-2024-10-01', 'gpt-4o-audio-preview-2024-12-17', 'gpt-4o-audio-preview-2025-06-03', 'gpt-4o-mini-audio-preview', 'gpt-4o-mini-audio-preview-2024-12-17', 'gpt-4o-search-preview', 'gpt-4o-mini-search-preview', 'gpt-4o-search-preview-2025-03-11', 'gpt-4o-mini-search-preview-2025-03-11', 'chatgpt-4o-latest', 'codex-mini-latest', 'gpt-4o-mini', 'gpt-4o-mini-2024-07-18', 'gpt-4-turbo', 'gpt-4-turbo-2024-04-09', 'gpt-4-0125-preview', 'gpt-4-turbo-preview', 'gpt-4-1106-preview', 'gpt-4-vision-preview', 'gpt-4', 'gpt-4-0314', 'gpt-4-0613', 'gpt-4-32k', 'gpt-4-32k-0314', 'gpt-4-32k-0613', 'gpt-3.5-turbo', 'gpt-3.5-turbo-16k', 'gpt-3.5-turbo-0301', 'gpt-3.5-turbo-0613', 'gpt-3.5-turbo-1106', 'gpt-3.5-turbo-0125', 'gpt-3.5-turbo-16k-0613'], stream: Literal[True], audio: ChatCompletionAudioParam | NotGiven | None = ..., frequency_penalty: float | NotGiven | None = ..., function_call: Literal['none', 'auto'] | ChatCompletionFunctionCallOptionParam | NotGiven = ..., functions: Iterable[Function] | NotGiven = ..., logit_bias: dict[str, int] | NotGiven | None = ..., logprobs: bool | NotGiven | None = ..., max_completion_tokens: int | NotGiven | None = ..., max_tokens: int | NotGiven | None = ..., metadata: dict[str, str] | NotGiven | None = ..., modalities: list[Literal['text', 'audio']] | NotGiven | None = ..., n: int | NotGiven | None = ..., parallel_tool_calls: bool | NotGiven = ..., prediction: ChatCompletionPredictionContentParam | NotGiven | None = ..., presence_penalty: float | NotGiven | None = ..., reasoning_effort: Literal['low', 'medium', 'high'] | None | NotGiven | None = ..., response_format: ResponseFormatText | ResponseFormatJSONSchema | ResponseFormatJSONObject | NotGiven = ..., seed: int | NotGiven | None = ..., service_tier: Literal['auto', 'default', 'flex'] | NotGiven | None = ..., stop: str | list[str] | NotGiven | None = ..., store: bool | NotGiven | None = ..., stream_options: ChatCompletionStreamOptionsParam | NotGiven | None = ..., temperature: float | NotGiven | None = ..., tool_choice: Literal['none', 'auto', 'required'] | ChatCompletionNamedToolChoiceParam | NotGiven = ..., tools: Iterable[ChatCompletionToolParam] | NotGiven = ..., top_logprobs: int | NotGiven | None = ..., top_p: float | NotGiven | None = ..., user: str | NotGiven = ..., web_search_options: WebSearchOptions | NotGiven = ..., extra_headers: Mapping[str, str | Omit] | None = ..., extra_query: Mapping[str, object] | None = ..., extra_body: object | None = ..., timeout: float | Timeout | NotGiven | None = ...) -> Stream[ChatCompletionChunk]
505:  text_to_audio/utils.py:444: note:     def create(self, *, messages: Iterable[ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam | ChatCompletionUserMessageParam | ChatCompletionAssistantMessageParam | ChatCompletionToolMessageParam | ChatCompletionFunctionMessageParam], model: str | Literal['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4.1-2025-04-14', 'gpt-4.1-mini-2025-04-14', 'gpt-4.1-nano-2025-04-14', 'o4-mini', 'o4-mini-2025-04-16', 'o3', 'o3-2025-04-16', 'o3-mini', 'o3-mini-2025-01-31', 'o1', 'o1-2024-12-17', 'o1-preview', 'o1-preview-2024-09-12', 'o1-mini', 'o1-mini-2024-09-12', 'gpt-4o', 'gpt-4o-2024-11-20', 'gpt-4o-2024-08-06', 'gpt-4o-2024-05-13', 'gpt-4o-audio-preview', 'gpt-4o-audio-preview-2024-10-01', 'gpt-4o-audio-preview-2024-12-17', 'gpt-4o-audio-preview-2025-06-03', 'gpt-4o-mini-audio-preview', 'gpt-4o-mini-audio-preview-2024-12-17', 'gpt-4o-search-preview', 'gpt-4o-mini-search-preview', 'gpt-4o-search-preview-2025-03-11', 'gpt-4o-mini-search-preview-2025-03-11', 'chatgpt-4o-latest', 'codex-mini-latest', 'gpt-4o-mini', 'gpt-4o-mini-2024-07-18', 'gpt-4-turbo', 'gpt-4-turbo-2024-04-09', 'gpt-4-0125-preview', 'gpt-4-turbo-preview', 'gpt-4-1106-preview', 'gpt-4-vision-preview', 'gpt-4', 'gpt-4-0314', 'gpt-4-0613', 'gpt-4-32k', 'gpt-4-32k-0314', 'gpt-4-32k-0613', 'gpt-3.5-turbo', 'gpt-3.5-turbo-16k', 'gpt-3.5-turbo-0301', 'gpt-3.5-turbo-0613', 'gpt-3.5-turbo-1106', 'gpt-3.5-turbo-0125', 'gpt-3.5-turbo-16k-0613'], stream: bool, audio: ChatCompletionAudioParam | NotGiven | None = ..., frequency_penalty: float | NotGiven | None = ..., function_call: Literal['none', 'auto'] | ChatCompletionFunctionCallOptionParam | NotGiven = ..., functions: Iterable[Function] | NotGiven = ..., logit_bias: dict[str, int] | NotGiven | None = ..., logprobs: bool | NotGiven | None = ..., max_completion_tokens: int | NotGiven | None = ..., max_tokens: int | NotGiven | None = ..., metadata: dict[str, str] | NotGiven | None = ..., modalities: list[Literal['text', 'audio']] | NotGiven | None = ..., n: int | NotGiven | None = ..., parallel_tool_calls: bool | NotGiven = ..., prediction: ChatCompletionPredictionContentParam | NotGiven | None = ..., presence_penalty: float | NotGiven | None = ..., reasoning_effort: Literal['low', 'medium', 'high'] | None | NotGiven | None = ..., response_format: ResponseFormatText | ResponseFormatJSONSchema | ResponseFormatJSONObject | NotGiven = ..., seed: int | NotGiven | None = ..., service_tier: Literal['auto', 'default', 'flex'] | NotGiven | None = ..., stop: str | list[str] | NotGiven | None = ..., store: bool | NotGiven | None = ..., stream_options: ChatCompletionStreamOptionsParam | NotGiven | None = ..., temperature: float | NotGiven | None = ..., tool_choice: Literal['none', 'auto', 'required'] | ChatCompletionNamedToolChoiceParam | NotGiven = ..., tools: Iterable[ChatCompletionToolParam] | NotGiven = ..., top_logprobs: int | NotGiven | None = ..., top_p: float | NotGiven | None = ..., user: str | NotGiven = ..., web_search_options: WebSearchOptions | NotGiven = ..., extra_headers: Mapping[str, str | Omit] | None = ..., extra_query: Mapping[str, object] | None = ..., extra_body: object | None = ..., timeout: float | Timeout | NotGiven | None = ...) -> ChatCompletion | Stream[ChatCompletionChunk]
506:  text_to_audio/services/chunk_tone_service.py:334: error: Returning Any from function declared to return "dict[Any, Any]"  [no-any-return]
507:  tests/test_article_deletion_safety.py:253: error: "Feed" has no attribute "id"  [attr-defined]
508:  tests/test_article_deletion_safety.py:253: error: "Article" has no attribute "id"  [attr-defined]
509:  tests/test_article_deletion_safety.py:262: error: "Article" has no attribute "id"  [attr-defined]
510:  tests/text_to_audio/test_gpt_url_extraction.py:181: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "str | None"; expected "Iterable[Any] | Container[Any]"  [arg-type]
511:  tests/text_to_audio/test_content_analysis_service.py:139: error: Argument "request" to "APIError" has incompatible type "None"; expected "Request"  [arg-type]
512:  tests/text_to_audio/test_chunk_tone_service.py:85: error: Unsupported right operand type for in ("str | None")  [operator]
513:  tests/text_to_audio/test_chunk_tone_service.py:89: error: Unsupported right operand type for in ("str | None")  [operator]
514:  tests/text_to_audio/test_chunk_tone_service.py:157: error: Unsupported right operand type for in ("str | None")  [operator]
515:  tests/text_to_audio/test_chunk_tone_service.py:182: error: Unsupported right operand type for in ("str | None")  [operator]
516:  tests/text_to_audio/test_chunk_tone_service.py:206: error: Unsupported right operand type for in ("str | None")  [operator]
517:  tests/text_to_audio/test_chunk_tone_service.py:218: error: Unsupported right operand type for in ("str | None")  [operator]
518:  text_to_audio/services/voice_configuration.py:299: error: Returning Any from function declared to return "str"  [no-any-return]
519:  tests/test_voice_single_source_fix.py:20: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
520:  tests/test_voice_single_source_fix.py:114: error: Cannot assign to a method  [method-assign]
521:  tests/test_voice_single_source_fix.py:118: error: Cannot assign to a method  [method-assign]
522:  tests/test_voice_single_source_fix.py:122: error: Cannot assign to a method  [method-assign]
523:  tests/test_voice_single_source_fix.py:144: error: Cannot assign to a method  [method-assign]
524:  tests/test_voice_single_source_fix.py:148: error: Cannot assign to a method  [method-assign]
525:  tests/test_voice_single_source_fix.py:152: error: Cannot assign to a method  [method-assign]
526:  text_to_audio/tasks.py:1374: error: Need type annotation for "all_voices" (hint: "all_voices: dict[<type>, <type>] = ...")  [var-annotated]
527:  tests/test_article_prompt.py:19: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
528:  text_to_audio/views.py:184: error: Item "None" of "Any | None" has no attribute "is_superuser"  [union-attr]
529:  text_to_audio/views.py:185: error: Item "None" of "Any | None" has no attribute "is_staff"  [union-attr]
530:  text_to_audio/views.py:186: error: Item "None" of "Any | None" has no attribute "save"  [union-attr]
531:  text_to_audio/views.py:192: error: Item "None" of "Any | None" has no attribute "username"  [union-attr]
532:  text_to_audio/views.py:413: error: "_ST" has no attribute "id"  [attr-defined]
533:  text_to_audio/views.py:446: error: Incompatible types in assignment (expression has type "_ST", variable has type "UserVoicePreset")  [assignment]
534:  text_to_audio/views.py:588: error: Argument 1 to "float" has incompatible type "Any | None"; expected "str | Buffer | SupportsFloat | SupportsIndex"  [arg-type]
535:  text_to_audio/views.py:615: error: "_ST" has no attribute "pk"  [attr-defined]
536:  text_to_audio/views.py:873: error: "_ST" has no attribute "id"  [attr-defined]
537:  tests/text_to_audio/test_views_followed_feed.py:19: error: "type[FollowedFeedUITests]" has no attribute "user"  [attr-defined]
538:  tests/text_to_audio/test_views_followed_feed.py:19: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
539:  tests/text_to_audio/test_views_followed_feed.py:22: error: "type[FollowedFeedUITests]" has no attribute "other_user"  [attr-defined]
540:  tests/text_to_audio/test_views_followed_feed.py:22: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
541:  tests/text_to_audio/test_views_followed_feed.py:28: error: "type[FollowedFeedUITests]" has no attribute "user_destination_feed"  [attr-defined]
542:  tests/text_to_audio/test_views_followed_feed.py:29: error: "type[FollowedFeedUITests]" has no attribute "user"  [attr-defined]
543:  tests/text_to_audio/test_views_followed_feed.py:32: error: "type[FollowedFeedUITests]" has no attribute "user_destination_feed_2"  [attr-defined]
544:  tests/text_to_audio/test_views_followed_feed.py:33: error: "type[FollowedFeedUITests]" has no attribute "user"  [attr-defined]
545:  tests/text_to_audio/test_views_followed_feed.py:44: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
546:  tests/text_to_audio/test_views_followed_feed.py:46: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
547:  tests/text_to_audio/test_views_followed_feed.py:49: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
548:  tests/text_to_audio/test_views_followed_feed.py:51: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
549:  tests/text_to_audio/test_views_followed_feed.py:55: error: "FollowedFeedUITests" has no attribute "other_user"  [attr-defined]
550:  tests/text_to_audio/test_views_followed_feed.py:58: error: "FollowedFeedUITests" has no attribute "other_user"  [attr-defined]
551:  tests/text_to_audio/test_views_followed_feed.py:80: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
552:  tests/text_to_audio/test_views_followed_feed.py:87: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
553:  tests/text_to_audio/test_views_followed_feed.py:95: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
554:  tests/text_to_audio/test_views_followed_feed.py:100: error: Argument 1 to "assertFormError" of "SimpleTestCase" has incompatible type "_MonkeyPatchedWSGIResponse"; expected "Form"  [arg-type]
555:  tests/text_to_audio/test_views_followed_feed.py:102: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
556:  tests/text_to_audio/test_views_followed_feed.py:108: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
557:  tests/text_to_audio/test_views_followed_feed.py:110: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
558:  tests/text_to_audio/test_views_followed_feed.py:122: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
559:  tests/text_to_audio/test_views_followed_feed.py:124: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
560:  tests/text_to_audio/test_views_followed_feed.py:129: error: "FollowedFeedUITests" has no attribute "user_destination_feed_2"  [attr-defined]
561:  tests/text_to_audio/test_views_followed_feed.py:139: error: "FollowedFeedUITests" has no attribute "user_destination_feed_2"  [attr-defined]
562:  tests/text_to_audio/test_views_followed_feed.py:145: error: "FollowedFeedUITests" has no attribute "other_user"  [attr-defined]
563:  tests/text_to_audio/test_views_followed_feed.py:148: error: "FollowedFeedUITests" has no attribute "other_user"  [attr-defined]
564:  tests/text_to_audio/test_views_followed_feed.py:161: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
565:  tests/text_to_audio/test_views_followed_feed.py:163: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
566:  tests/text_to_audio/test_views_followed_feed.py:174: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
567:  tests/text_to_audio/test_views_followed_feed.py:176: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
568:  tests/text_to_audio/test_views_followed_feed.py:188: error: "FollowedFeedUITests" has no attribute "other_user"  [attr-defined]
569:  tests/text_to_audio/test_views_followed_feed.py:191: error: "FollowedFeedUITests" has no attribute "other_user"  [attr-defined]
570:  tests/text_to_audio/test_views_followed_feed.py:205: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
571:  tests/text_to_audio/test_views_followed_feed.py:208: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
572:  tests/text_to_audio/test_views_followed_feed.py:215: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
573:  tests/text_to_audio/test_views_followed_feed.py:217: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
574:  tests/text_to_audio/test_views_followed_feed.py:225: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
575:  tests/text_to_audio/test_views_followed_feed.py:233: error: "FollowedFeedUITests" has no attribute "other_user"  [attr-defined]
576:  tests/text_to_audio/test_views_followed_feed.py:236: error: "FollowedFeedUITests" has no attribute "user"  [attr-defined]
577:  tests/text_to_audio/test_views_followed_feed.py:237: error: "Field" has no attribute "queryset"  [attr-defined]
578:  tests/text_to_audio/test_views_followed_feed.py:239: error: "FollowedFeedUITests" has no attribute "user_destination_feed"  [attr-defined]
579:  tests/text_to_audio/test_views_followed_feed.py:240: error: "FollowedFeedUITests" has no attribute "user_destination_feed_2"  [attr-defined]
580:  tests/text_to_audio/test_views_followed_feed.py:247: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
581:  tests/text_to_audio/test_views_followed_feed.py:254: error: "Field" has no attribute "queryset"  [attr-defined]
582:  tests/text_to_audio/test_views_followed_feed.py:261: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
583:  tests/text_to_audio/test_views_followed_feed.py:275: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
584:  tests/text_to_audio/test_views_followed_feed.py:288: error: Argument 1 to "assertFormError" of "SimpleTestCase" has incompatible type "_MonkeyPatchedWSGIResponse"; expected "Form"  [arg-type]
585:  tests/text_to_audio/test_speed_control_chunk_tone.py:47: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
586:  tests/text_to_audio/test_speed_control_chunk_tone.py:160: error: "Article" has no attribute "id"  [attr-defined]
587:  tests/text_to_audio/test_speed_control_chunk_tone.py:163: error: "Article" has no attribute "id"  [attr-defined]
588:  tests/text_to_audio/test_speed_control_chunk_tone.py:167: error: "Article" has no attribute "id"  [attr-defined]
589:  tests/text_to_audio/test_speed_control_chunk_tone.py:170: error: "Article" has no attribute "id"  [attr-defined]
590:  tests/text_to_audio/test_speed_control_chunk_tone.py:285: error: "Article" has no attribute "id"  [attr-defined]
591:  tests/text_to_audio/test_speed_control_chunk_tone.py:288: error: "Article" has no attribute "id"  [attr-defined]
592:  tests/text_to_audio/test_speed_control_chunk_tone.py:385: error: "Article" has no attribute "id"  [attr-defined]
593:  tests/text_to_audio/test_speed_control_chunk_tone.py:388: error: "Article" has no attribute "id"  [attr-defined]
594:  tests/test_canonical_media_paths.py:24: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
595:  tests/test_canonical_media_paths.py:96: error: "Article" has no attribute "id"  [attr-defined]
596:  tests/test_canonical_media_paths.py:144: error: "Manager[AbstractBaseUser]" has no attribute "create_user"  [attr-defined]
597:  Found 131 errors in 20 files (checked 100 source files)
598:  ##[error]Process completed with exit code 1.
599:  Post job cleanup.

@github-actions

github-actions Bot commented Jun 6, 2025

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 30b2ab1

@qodo-code-review

qodo-code-review Bot commented Jun 6, 2025

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 30b2ab1
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Filter chunk analyses by summary

Modify the condition to ensure each chunk_analysis includes a summary before
appending, preventing missing summaries in the combined result.

text_to_audio/tasks.py [511]

-if chunk_analysis and "audio_segments" in chunk_analysis: # Ensure summary is also present if needed for combined
+if chunk_analysis and "audio_segments" in chunk_analysis and "summary" in chunk_analysis:
Suggestion importance[1-10]: 6

__

Why: Changing the condition to require "summary" avoids appending chunks without summaries, preventing empty summaries in the combined result.

Low
Isolate mock responses

The second scenario re-uses the same mock_response and mock_message without updating
or resetting the mock, so it still contains the first test’s data. Instantiate a
fresh response or call reset_mock() on the client between cases to isolate them.
Alternatively, split into two separate test methods.

tests/text_to_audio/test_content_analysis_service.py [151-176]

 # Test case 1: Empty voices list
-mock_message.content = json.dumps({
+mock_response1 = MagicMock()
+mock_message1 = MagicMock()
+mock_message1.content = json.dumps({
     "summary": "Summary exists",
-    "voices": [], # Empty voices
+    "voices": [],
     "audio_segments": [{"text": "Segment 1", "voice_name": "narrator"}]
 })
-self.mock_openai_client.chat.completions.create.return_value = mock_response
-sample_text = "Sample text for empty voices test."
-result1 = self.service.analyze_content(sample_text)
+mock_response1.choices = [MagicMock(message=mock_message1)]
+self.mock_openai_client.chat.completions.create.return_value = mock_response1
+result1 = self.service.analyze_content("Sample text for empty voices test.")
 ...
 # Test case 2: Empty audio_segments list
-mock_message.content = json.dumps({
+mock_response2 = MagicMock()
+mock_message2 = MagicMock()
+mock_message2.content = json.dumps({
     "summary": "Summary exists",
     "voices": [{"name": "narrator", "tone": "neutral", "tts_model": "alloy", "tts_speed": 1.0}],
-    "audio_segments": [] # Empty segments
+    "audio_segments": []
 })
-self.mock_openai_client.chat.completions.create.return_value = mock_response # Re-assign if changed
-sample_text_2 = "Sample text for empty segments test."
-result2 = self.service.analyze_content(sample_text_2)
+mock_response2.choices = [MagicMock(message=mock_message2)]
+self.mock_openai_client.chat.completions.create.return_value = mock_response2
+result2 = self.service.analyze_content("Sample text for empty segments test.")
Suggestion importance[1-10]: 4

__

Why: The test already overwrites mock_message.content and reassigns mock_response before each call, so there is no shared state; refactoring is stylistic with low impact.

Low
General
Pick first valid summary

Replace the simplistic first-chunk summary selection with a loop that picks the
first non-empty summary across all chunks, ensuring a valid summary is merged.

text_to_audio/tasks.py [1376-1380]

 combined_summary = ""
+# Select the first non-empty summary across chunks
+for ca in chunk_analyses:
+    summary_text = ca.get("summary", "").strip()
+    if summary_text:
+        combined_summary = summary_text
+        break
 
-# Use summary from the first chunk if available, or concatenate summaries (simple approach here)
-if chunk_analyses and chunk_analyses[0].get("summary"):
-    combined_summary = chunk_analyses[0].get("summary", "")
-
Suggestion importance[1-10]: 6

__

Why: Selecting the first non-empty summary across all chunks ensures a valid summary is chosen even if the first chunk’s summary is empty.

Low
Access summary directly

Since summary is already validated for presence and type, access it directly and
strip whitespace to ensure a clean, non-empty string.

text_to_audio/services/content_analysis.py [253]

-summary = result.get("summary", "") # Default to empty string if not found
+summary = result["summary"].strip()
Suggestion importance[1-10]: 4

__

Why: Directly indexing result["summary"] and stripping whitespace cleans up the string and relies on the prior validation, though the improvement is stylistic.

Low
Remove duplicate settings import
Suggestion Impact:The commit implemented the suggestion by removing the duplicate import and consolidating to use only the aliased import. The code was restructured to use a multi-line import format with the alias and comment.

code diff:

-from django.conf import settings
+from django.conf import (
+    settings as django_settings,  # Use a different alias to avoid conflict with fixture
+)
 from django.contrib.auth import get_user_model
-from django.conf import settings as django_settings # Use a different alias to avoid conflict with fixture

You’ve imported settings twice under different names. Remove the redundant from
django.conf import settings and use only django_settings to avoid confusion between
globals and test‐specific configuration.

tests/text_to_audio/test_tasks.py [15-17]

-from django.conf import settings
-from django.conf import settings as django_settings # Use a different alias to avoid conflict with fixture
+from django.conf import settings as django_settings # Configure at top
Suggestion importance[1-10]: 3

__

Why: The second from django.conf import settings as django_settings shadows the first import, making the unaliased import redundant; removing it clarifies the code but has minimal impact.

Low

Previous suggestions

✅ Suggestions up to commit 30b2ab1
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix test comment-code mismatch

The test comment contradicts the assertions. It states that missing keys cause a
ValueError and return a default structure, but the test is asserting specific
values without verifying this behavior. Either update the assertions to match
the described behavior or correct the comment.

tests/text_to_audio/test_content_analysis_service.py [94-107]

-# The service's current behavior on missing keys (like 'summary' here)
-# is to raise ValueError in the try block, then the except block returns a fully default structure.
-expected_default_voices = [{
-    "name": "narrator",
-    "tone": "Neutral, standard narration",
-    "tts_model": "alloy",
-    "tts_speed": 1.0,
-}]
-expected_default_segments = [{
-    "text": sample_text, # The original full text is used in fallback
-    "voice_name": "narrator",
-}]
-self.assertEqual(result.get("voices"), expected_default_voices)
-self.assertEqual(result.get("audio_segments"), expected_default_segments)
+# When summary is missing but other fields are valid, the service should still use those fields
+# and only set a default empty summary
+self.assertEqual(result.get("summary"), "")
+# The rest of the structure should come from the mock response
+self.assertEqual(result.get("voices"), expected_voices)
+self.assertEqual(result.get("audio_segments"), expected_segments)
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why: Valid concern about inconsistency between test comments and actual assertions. The comment describes fallback behavior but the assertions expect the mocked response values, which creates confusion about the expected behavior.

Low
Check for null before access

The test is missing a check to verify that the article's multi_voice_data is
actually set before accessing its "summary" key. If the API error occurs before
multi_voice_data is set, this test will fail with a TypeError or AttributeError.

tests/text_to_audio/test_tasks.py [637-639]

 @patch("text_to_audio.tasks.ContentAnalysisService")
 @patch("text_to_audio.tasks.AudioSegment.from_mp3")
 @patch("text_to_audio.tasks.AudioSegment.empty")
 def test_process_article_failure_after_summary_saved(self, mock_audio_empty, mock_audio_from_mp3, MockCAS, MockOpenAIClient):
     self._setup_audio_mocks(mock_audio_empty, mock_audio_from_mp3)
     expected_summary = "Summary generated before TTS failure."
     analysis_result = {
         "summary": expected_summary,
         "voices": [{"name": "narrator", "tone": "neutral", "tts_model": "alloy", "tts_speed": 1.0}],
         "audio_segments": [{"text": self.article.text_content, "voice_name": "narrator"}]
     }
     MockCAS.return_value.analyze_content.return_value = analysis_result
 
     mock_openai_instance = MockOpenAIClient.return_value
     mock_speech_create = mock_openai_instance.audio.speech.create
     mock_speech_create.side_effect = OpenAIAPIError("Simulated TTS API Error", request=MagicMock(), body=None)
 
     with patch("text_to_audio.tasks.process_article.retry", side_effect=Exception("Celery retry for test")), \
          patch("text_to_audio.tasks._is_valid_multi_voice_data", return_value=True), \
          patch("text_to_audio.tasks._save_openai_usage_stats"):
         with self.assertRaisesRegex(Exception, "Celery retry for test"):
             process_article(self.article.id)
     self.article.refresh_from_db()
     self.assertEqual(self.article.summary, expected_summary)
+    self.assertIsNotNone(self.article.multi_voice_data)
     self.assertEqual(self.article.multi_voice_data["summary"], expected_summary)
     self.assertEqual(self.article.status, Article.FAILED)

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 4

__

Why: Valid defensive programming suggestion that adds a null check before accessing multi_voice_data["summary"]. While the test is designed to have this data set, the additional assertion makes the test more robust and would catch unexpected test setup issues.

Low
General
Fix misleading comment
Suggestion Impact:The commit reformatted the code by splitting the condition across multiple lines but kept the misleading comment unchanged, addressing the formatting aspect but not the core issue about the misleading comment content

code diff:

-                        if chunk_analysis and "audio_segments" in chunk_analysis: # Ensure summary is also present if needed for combined
+                        if (
+                            chunk_analysis and "audio_segments" in chunk_analysis
+                        ):  # Ensure summary is also present if needed for combined

The comment is misleading as the code only checks for "audio_segments" but not
for "summary". This could lead to chunks without summaries being processed
incorrectly. Either remove the comment or update the condition to also check for
"summary" if that's required.

text_to_audio/tasks.py [511-512]

-if chunk_analysis and "audio_segments" in chunk_analysis: # Ensure summary is also present if needed for combined
+if chunk_analysis and "audio_segments" in chunk_analysis:
     chunk_analyses.append(chunk_analysis)

[Suggestion processed]

Suggestion importance[1-10]: 5

__

Why: Good catch on misleading comment. The comment mentions ensuring summary is present but the condition only checks for audio_segments, which could confuse future developers.

Low
Remove unnecessary comments

Remove the commented code that creates confusion about how summaries are
handled. The implementation is already complete with _combine_chunk_analyses
handling the summary and the code correctly using
combined_analysis.get("summary", "").

text_to_audio/tasks.py [518-523]

 if chunk_analyses:
-    combined_analysis = _combine_chunk_analyses(chunk_analyses) # This helper needs to handle summary
+    combined_analysis = _combine_chunk_analyses(chunk_analyses)
     article.multi_voice_data = combined_analysis
-    # Assuming summary from the first chunk or a combined summary is handled by _combine_chunk_analyses
-    # If not, this needs to be set explicitly e.g. article.summary = chunk_analyses[0].get("summary", "") if chunk_analyses else ""
-    article.summary = combined_analysis.get("summary", "") # _combine_chunk_analyses should add this
+    article.summary = combined_analysis.get("summary", "")
  • Apply / Chat
Suggestion importance[1-10]: 4

__

Why: Valid suggestion to remove unnecessary comments that add clutter. The code already handles summary correctly through _combine_chunk_analyses function, making the comments redundant.

Low
  • Update

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: LLM Response Handling Conflict

Inconsistent handling of the 'summary' field from the LLM response. The code validates that 'summary' must be present and raises an error if missing, but then attempts to extract it using .get("summary", "") with a default value. This makes the default value unreachable and contradicts the likely intent to handle missing summaries gracefully.

text_to_audio/services/content_analysis.py#L233-L253

# Validate the structure
if "voices" not in result or "audio_segments" not in result or "summary" not in result:
raise ValueError(
"Missing 'voices', 'audio_segments', or 'summary' in LLM response."
)
if not isinstance(result["voices"], list) or not isinstance(
result["audio_segments"], list
):
raise ValueError("'voices' and 'audio_segments' must be lists.")
if not isinstance(result["summary"], str):
raise ValueError("'summary' must be a string.")
if not result["voices"]: # Must have at least one voice
raise ValueError("'voices' list cannot be empty.")
if not result["audio_segments"]: # Must have at least one segment
raise ValueError("'audio_segments' list cannot be empty.")
# Further validation for each voice and segment can be added here if needed
# For example, check if all voice_names in audio_segments refer to defined voices.
# Extract summary
summary = result.get("summary", "") # Default to empty string if not found

Fix in Cursor


BugBot free trial expires on June 12, 2025
You have used $0.00 of your $50.00 spend limit so far. Manage your spend limit in the Cursor dashboard.

Was this report helpful? Give feedback by reacting with 👍 or 👎

jmaddington and others added 3 commits June 20, 2025 22:59
- Remove unused imports and variables
- Fix semicolon and colon syntax issues
- Fix indentation issues in test files
- Remove unused clause_breaks variable

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Enable users to upload PDF, HTML, or text files instead of just URLs/text.
Files are processed with GPT-4.1 for intelligent content extraction and
flow through the existing article processing pipeline.

Features:
- Support for PDF, HTML, and TXT file uploads (max 50MB)
- Intelligent text extraction with GPT-4.1 preprocessing
- Automatic title generation from content or filename
- Form validation ensuring single input method
- Comprehensive test coverage for file processing
- Seamless integration with existing TTS pipeline

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Apply black and isort formatting to resolve pre-commit hook issues.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@jmaddington jmaddington changed the title Add article summarization for RSS feeds Add article summarization and PDF/HTML file upload support Jun 20, 2025
jmaddington added a commit that referenced this pull request Jun 21, 2025
jmaddington added a commit that referenced this pull request Jun 26, 2025
* Add article summarization for RSS feeds

This feature introduces article summarization into the processing pipeline.

Key changes:
- The `ContentAnalysisService` has been updated to instruct the LLM
  to generate a 2-3 sentence summary of the article content. This
  summary is then parsed from the LLM's response.
- The `process_article` Celery task now retrieves this summary and
  saves it to the `summary` field of the `Article` model.
- The RSS feed generation (`UserFeed` in `feeds.py`) already utilized
  the `article.summary` field, so generated summaries will now
  automatically be included in the feed item descriptions.
- Added `docs/pipeline_overview.md` to document the end-to-end
  article processing pipeline, including the new summarization step.
- Added unit tests for `ContentAnalysisService` to verify summary
  extraction.
- Updated tests for the `process_article` task in `test_tasks.py`
  to ensure summaries are correctly saved and handled, including
  a new focused test for summary saving and fixes to existing
  complex tests like `test_content_analysis_called_once_for_auto_feed`.

* docs: expand CLAUDE.md with guidelines for anchor comments, commit discipline, AGENTS.md updates, and AI assistant workflow

- Added detailed guidelines for using anchor comments to enhance code documentation.
- Introduced a section on commit discipline to promote clear and meaningful commit messages.
- Included instructions for maintaining directory-specific AGENTS.md files and updating them as necessary.
- Outlined a step-by-step methodology for AI assistant workflows to ensure clarity and correctness in responses.

This update aims to improve documentation practices and assist developers in maintaining high-quality code and collaboration standards.

* docs: update CLAUDE.md and docker-compose.yml to clarify Docker environment setup

- Added a note in CLAUDE.md indicating that the `IN_DOCKER` environmental variable being set to `True` signifies that the application is running inside Docker.
- Updated docker-compose.yml to include the `IN_DOCKER` variable in the default environment settings for the development container.

These changes aim to enhance clarity regarding the development environment configuration.

* chore: update requirements.txt to specify versions for PyPDF2 and add pydantic

- Updated PyPDF2 to require version >=3.0.0 and <4.0.0 for compatibility.
- Added pydantic with version constraints >=2.0 and <3.0 to the requirements.

These changes ensure better dependency management and compatibility with future updates.

* fix: resolve 14 failing test categories and update OpenAI API usage

This commit addresses multiple test failures and updates the codebase to work with current OpenAI API standards:

**API Updates:**
- Update ContentAnalysisService to use `max_completion_tokens` instead of deprecated `max_tokens` parameter
- Update MODEL_TOKEN_LIMITS with current OpenAI model context windows (128k for GPT-4o/GPT-4o-mini)
- Fix token calculation logic to properly handle total context vs completion tokens

**Migration Command Fixes:**
- Fix audio path migration rollback functionality by tracking file moves immediately after execution
- Fix test data setup to use proper legacy audio file paths for migration testing
- Improve error handling and rollback operations in migrate_audio_paths command

**Test Fixes:**
- Update Article model tests to reflect current title field max_length (1024 vs 255)
- Fix OpenAIUsageStats __str__ method tests to expect "LLM usage" prefix
- Fix service test mock setup to properly return JSON strings instead of MagicMock objects
- Correct migration test expectations for permission errors and rollback scenarios

**Results:**
- Reduced failing tests from 43 to 29 (33% improvement)
- Fixed all migration, model, content analysis, and service test categories
- Improved test suite stability and reliability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve 8 failing tests - improved test suite pass rate from 89.8% to 94.1%

- test_migration_handles_missing_files: Fix logic error expecting 'audio/' path for unchanged legacy files
- test_get_feed_voice_mode_default: Update expectation to 'auto' matching Feed model default
- test_post_creates_new_article: Fix form field name from 'voice' to 'voice_id' for ArticleDetailForm
- test_apply_preset_to_article: Correct assertion to check standard voices in 'voice' field per single source of truth
- test_preset_edit_view: Use valid speed choice (0.9) and fix redirect assertion
- test_process_article_permanent_error_no_retry: Add missing OpenAI client mock
- test_worker_service_uses_start_worker_script: Correct expectation for worker script vs web script
- test_devcontainer_updated: Update docker-in-docker version to current 2.12.2

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve 10 failing tests across multiple test suites

- Fixed text chunking logic in _legacy_find_best_break_point to properly detect forced splits
- Improved multi-voice test mocking and disabled ChunkToneService for legacy path testing
- Added missing OpenAI client mock in url_import test
- Updated views_followed_feed tests for Django 5.2 compatibility (assertFormError → context access)
- Fixed HTML entity encoding in test assertions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve failing tests by enhancing MockAudioSegment and ChunkToneService voice handling

- Add missing methods to MockAudioSegment: silent(), set_frame_rate(), __iadd__(), duration_seconds
- Fix ChunkToneService to respect article voice settings in fallback scenarios
- Update get_payload() and create_fallback_payload() to accept fallback_voice parameter
- Add fallback voice determination logic in tasks.py before ChunkToneService calls
- Fix test voice field usage: use 'voice' field for standard OpenAI voices instead of 'voice_id'
- Add override_settings for legacy path tests to bypass ChunkToneService when testing ContentAnalysisService

Resolves AudioSegment.silent AttributeError and improves voice configuration consistency.
Fixed 5 out of 11 failing tests - remaining failures are test infrastructure mocking issues.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix linting issues in PR #135

- Remove unused imports and variables
- Fix semicolon and colon syntax issues
- Fix indentation issues in test files
- Remove unused clause_breaks variable

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add article submission API

- Create REST API endpoint for submitting articles via API
- Add API documentation with drf-spectacular
- Display API URL on feed detail page with copy button
- Add tests for article submission API
- Support both text and URL submission methods

This implementation allows for programmatic article creation
via a simple RESTful API, making feed automation easier.

* Apply Black code formatting

* Fix pre-commit hook issues after merge

- Remove duplicate silent method in MockAudioSegment
- Apply Black code formatting to updated files
- Fix end-of-file issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants