youread 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
youread/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,201 @@
1
+ """Article generation using LLM providers."""
2
+
3
+ import math
4
+
5
+ from pathlib import Path
6
+ from typing import Optional, Literal
7
+ from importlib.resources import files
8
+
9
+ from .models import VideoInfo
10
+ from .providers import call_llm
11
+
12
+
13
+ # Valid mode choices
14
+ ArticleMode = Literal["summary", "standard", "detailed"]
15
+
16
+ # Reading speed for technical content (words per minute)
17
+ # 150 WPM is realistic for technical/educational material requiring comprehension
18
+ TECHNICAL_WPM = 150
19
+
20
+
21
+ def calculate_reading_time(text: str, wpm: int = TECHNICAL_WPM) -> str:
22
+ """Calculate reading time from text content.
23
+
24
+ Args:
25
+ text: The article text to measure.
26
+ wpm: Words per minute reading speed.
27
+
28
+ Returns:
29
+ Formatted reading time string (e.g., "~5 min read").
30
+ """
31
+ # Count words (split on whitespace)
32
+ word_count = len(text.split())
33
+
34
+ # Calculate minutes, round up to nearest minute
35
+ minutes = math.ceil(word_count / wpm)
36
+
37
+ return f"~{minutes} min read"
38
+
39
+
40
+ def insert_reading_time(article: str) -> str:
41
+ """Replace the reading time placeholder with calculated reading time.
42
+
43
+ Args:
44
+ article: The generated article with READING_TIME_PLACEHOLDER.
45
+
46
+ Returns:
47
+ Article with accurate reading time inserted.
48
+ """
49
+ reading_time = calculate_reading_time(article)
50
+ return article.replace("READING_TIME_PLACEHOLDER", reading_time)
51
+
52
+
53
+ def load_prompt(prompt_path: Optional[Path] = None) -> str:
54
+ """Load a prompt from file.
55
+
56
+ Args:
57
+ prompt_path: Path to the prompt file. Defaults to prompts/system_prompt_v2.md.
58
+
59
+ Returns:
60
+ The prompt content as a string.
61
+ """
62
+ if prompt_path is None:
63
+ prompt_path = files("youread.prompts") / "system_prompt_v2.md"
64
+
65
+ if hasattr(prompt_path, 'read_text'):
66
+ return prompt_path.read_text(encoding="utf-8")
67
+
68
+ path = Path(prompt_path)
69
+ if not path.exists():
70
+ raise FileNotFoundError(f"Prompt file not found: {prompt_path}")
71
+
72
+ return path.read_text(encoding="utf-8")
73
+
74
+
75
+ def load_mode_prompt(mode: ArticleMode) -> str:
76
+ """Load the mode-specific prompt fragment.
77
+
78
+ Args:
79
+ mode: The article generation mode (summary, standard, detailed).
80
+
81
+ Returns:
82
+ The mode prompt content as a string.
83
+ """
84
+ mode_path = files("youread.prompts.modes") / f"{mode}.md"
85
+
86
+ if hasattr(mode_path, 'read_text'):
87
+ return mode_path.read_text(encoding="utf-8")
88
+
89
+ path = Path(mode_path)
90
+ if not path.exists():
91
+ raise FileNotFoundError(f"Mode prompt file not found: {mode_path}")
92
+
93
+ return path.read_text(encoding="utf-8")
94
+
95
+
96
+ def compose_system_prompt(
97
+ mode: ArticleMode = "standard",
98
+ custom_prompt: Optional[str] = None,
99
+ video_id: Optional[str] = None,
100
+ ) -> str:
101
+ """Compose the full system prompt from base + mode + custom.
102
+
103
+ Args:
104
+ mode: The article generation mode.
105
+ custom_prompt: Optional user-provided custom instructions.
106
+ video_id: Optional video ID for constructing the source URL.
107
+
108
+ Returns:
109
+ The composed system prompt.
110
+ """
111
+ # Load base prompt
112
+ base_prompt = load_prompt()
113
+
114
+ # Load mode-specific instructions
115
+ mode_instructions = load_mode_prompt(mode)
116
+
117
+ # Replace the mode placeholder
118
+ prompt = base_prompt.replace("{{MODE_INSTRUCTIONS}}", mode_instructions)
119
+
120
+ # Replace video URL placeholder if video_id provided
121
+ if video_id:
122
+ video_url = f"https://youtube.com/watch?v={video_id}"
123
+ prompt = prompt.replace("VIDEO_URL_PLACEHOLDER", video_url)
124
+
125
+ # Append custom prompt if provided
126
+ if custom_prompt:
127
+ custom_section = f"""
128
+ <custom_instructions>
129
+ The user has provided the following additional instructions. Follow them while maintaining the core quality standards:
130
+
131
+ {custom_prompt}
132
+ </custom_instructions>
133
+ """
134
+ prompt += custom_section
135
+
136
+ return prompt
137
+
138
+
139
+ def generate_article(
140
+ transcript_text: str,
141
+ video_info: VideoInfo,
142
+ provider: str,
143
+ api_key: str,
144
+ video_id: Optional[str] = None,
145
+ model: str = "gpt-5",
146
+ max_tokens: int = 8000,
147
+ temperature: float = 0.3,
148
+ mode: ArticleMode = "standard",
149
+ custom_prompt: Optional[str] = None,
150
+ ) -> str:
151
+ """Generate a readable article from a video transcript.
152
+
153
+ Args:
154
+ transcript_text: The full transcript text.
155
+ video_info: Video metadata (title, channel).
156
+ provider: LLM provider name (e.g., "openai", "gemini").
157
+ api_key: API key for the provider.
158
+ video_id: Video ID for source link.
159
+ model: Model identifier to use.
160
+ max_tokens: Maximum tokens in the response.
161
+ temperature: Sampling temperature (0.0-1.0).
162
+ mode: Article generation mode (summary, standard, detailed).
163
+ custom_prompt: Optional custom instructions from user.
164
+
165
+ Returns:
166
+ The generated article as markdown text.
167
+
168
+ Raises:
169
+ Exception: If the API call fails.
170
+ """
171
+ # Compose the system prompt
172
+ system_prompt = compose_system_prompt(
173
+ mode=mode,
174
+ custom_prompt=custom_prompt,
175
+ video_id=video_id,
176
+ )
177
+
178
+ # Build the user prompt with context
179
+ user_prompt = f"""## Video Information
180
+ **Title:** {video_info.title}
181
+ **Channel:** {video_info.channel}
182
+
183
+ ## Transcript
184
+ {transcript_text}
185
+ """
186
+
187
+ # Generate the article via provider dispatch
188
+ raw_response = call_llm(
189
+ provider=provider,
190
+ api_key=api_key,
191
+ model=model,
192
+ system_prompt=system_prompt,
193
+ user_prompt=user_prompt,
194
+ max_tokens=max_tokens,
195
+ temperature=temperature,
196
+ )
197
+
198
+ # Calculate and insert accurate reading time
199
+ article = insert_reading_time(raw_response)
200
+
201
+ return article
@@ -0,0 +1,9 @@
1
+ llm:
2
+ provider: openai
3
+ model: gpt-5
4
+ max_tokens: 8000
5
+ temperature: 0.3
6
+ output:
7
+ directory: ./output
8
+ processing:
9
+ remove_sponsors: true
youread/exceptions.py ADDED
@@ -0,0 +1,45 @@
1
+ """Custom exceptions for YouRead."""
2
+
3
+
4
+ class YouReadError(Exception):
5
+ """Base exception for all YouRead errors."""
6
+ pass
7
+
8
+
9
+ class InvalidURLError(YouReadError):
10
+ def __init__(self, url: str) -> None:
11
+ self.url = url
12
+ super().__init__(f"Invalid YouTube URL: {url}")
13
+
14
+
15
+ class TranscriptNotFoundError(YouReadError):
16
+ def __init__(self, video_id: str) -> None:
17
+ self.video_id = video_id
18
+ super().__init__(f"No transcript available for video: {video_id}")
19
+
20
+
21
+ class VideoNotFoundError(YouReadError):
22
+ def __init__(self, video_id: str) -> None:
23
+ self.video_id = video_id
24
+ super().__init__(f"Video not found or unavailable: {video_id}")
25
+
26
+
27
+ class UnsupportedProviderError(YouReadError):
28
+ def __init__(self, provider: str, supported: list[str]) -> None:
29
+ self.provider = provider
30
+ self.supported = supported
31
+ super().__init__(
32
+ f"Unsupported provider '{provider}'. "
33
+ f"Supported: {', '.join(supported)}"
34
+ )
35
+
36
+
37
+ class APIKeyMissingError(YouReadError):
38
+ def __init__(self, provider: str) -> None:
39
+ self.provider = provider
40
+ env_var = f"{provider.upper()}_API_KEY"
41
+ super().__init__(
42
+ f"API key for '{provider}' not found. "
43
+ f"Save {env_var} to YouRead's managed .env file, or use: "
44
+ f"youread config set --provider {provider} --set-api-key"
45
+ )