iwanfirmawan commited on
Commit
4e1cece
·
verified ·
1 Parent(s): 5bde2e9

feat: use modal storytelling mode

Browse files
README.md CHANGED
@@ -56,7 +56,7 @@ The app lets a user paste:
56
  Then the user can choose:
57
 
58
  - Output language: Indonesian (`id`) or English (`en`)
59
- - Tone
60
  - Target audience
61
  - Whether to include frontmatter
62
  - Whether to include code snippets
@@ -79,6 +79,13 @@ Transcript extraction has two paths:
79
  - Deterministic local extraction for Claude Code/Codex-style tool logs.
80
  - Optional Modal vLLM extraction when `MODAL_VLLM_BASE_URL` is configured.
81
 
 
 
 
 
 
 
 
82
  ## Hackathon Fit
83
 
84
  Vibe2Blog is planned as a Gradio app hosted on Hugging Face Spaces.
@@ -226,13 +233,30 @@ Without `HF_TOKEN` and `VIBE2BLOG_MODEL`, the app uses a deterministic template
226
  Optional Modal transcript extraction:
227
 
228
  ```bash
229
- export MODAL_VLLM_BASE_URL="https://your-workspace--your-app-serve.modal.run"
230
  export MODAL_VLLM_MODEL="llm"
 
231
  .venv/bin/python app.py
232
  ```
233
 
234
  The Modal endpoint should expose an OpenAI-compatible `/v1/chat/completions` route, such as the vLLM server pattern from Modal's official example.
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  When implementing:
237
 
238
  - Keep the first screen as the working generator.
 
56
  Then the user can choose:
57
 
58
  - Output language: Indonesian (`id`) or English (`en`)
59
+ - Tone, when Modal storytelling mode is not enabled
60
  - Target audience
61
  - Whether to include frontmatter
62
  - Whether to include code snippets
 
79
  - Deterministic local extraction for Claude Code/Codex-style tool logs.
80
  - Optional Modal vLLM extraction when `MODAL_VLLM_BASE_URL` is configured.
81
 
82
+ Editorial polishing also has two paths:
83
+
84
+ - Lightweight local quality scoring and deterministic cleanup.
85
+ - Optional Modal vLLM rewrite when `MODAL_POLISH_ENABLED=true` and `MODAL_VLLM_BASE_URL` is configured.
86
+
87
+ When Modal polishing is enabled, Vibe2Blog automatically uses a human storytelling editorial mode and ignores tone presets. Tone presets remain available only for deterministic fallback mode.
88
+
89
  ## Hackathon Fit
90
 
91
  Vibe2Blog is planned as a Gradio app hosted on Hugging Face Spaces.
 
233
  Optional Modal transcript extraction:
234
 
235
  ```bash
236
+ export MODAL_VLLM_BASE_URL="https://<workspace>--vibe2blog-backend-serve.modal.run/v1"
237
  export MODAL_VLLM_MODEL="llm"
238
+ export MODAL_VLLM_TIMEOUT="45"
239
  .venv/bin/python app.py
240
  ```
241
 
242
  The Modal endpoint should expose an OpenAI-compatible `/v1/chat/completions` route, such as the vLLM server pattern from Modal's official example.
243
 
244
+ Optional Modal editorial polishing:
245
+
246
+ ```bash
247
+ export MODAL_VLLM_BASE_URL="https://<workspace>--vibe2blog-backend-serve.modal.run/v1"
248
+ export MODAL_VLLM_MODEL="llm"
249
+ export MODAL_POLISH_ENABLED="true"
250
+ export MODAL_POLISH_TIMEOUT="60"
251
+ .venv/bin/python app.py
252
+ ```
253
+
254
+ When enabled, Vibe2Blog sends the generated Markdown draft to the Modal vLLM endpoint for a final editorial pass. The prompt instructs the model to preserve factual claims, YAML frontmatter, Markdown headings, file names, commands, verification results, and code blocks while improving flow, specificity, and readability. If Modal fails, the original draft is kept.
255
+
256
+ Modal may return an initial `303` redirect for web server calls. Vibe2Blog follows that redirect while preserving the original POST body.
257
+
258
+ In Modal storytelling mode, the UI hides tone presets and the model receives one editorial direction: write a natural technical story from the supplied context. Light transitions and connective sentences are allowed, but the model is instructed not to invent facts, files, tests, APIs, decisions, or outcomes.
259
+
260
  When implementing:
261
 
262
  - Keep the first screen as the working generator.
app.py CHANGED
@@ -2,31 +2,46 @@ from __future__ import annotations
2
 
3
  import gradio as gr
4
 
 
5
  from vibe2blog.pipeline import generate_article
6
 
7
 
8
- SAMPLE_SUMMARY = """We planned Vibe2Blog, a Gradio app for the Build Small Hackathon.
9
- The product changed from CLI-first to Gradio-first so judges can try it directly in a Hugging Face Space.
10
- Important decisions:
11
- - Gradio is the hackathon MVP.
12
- - CLI and slash commands are future extensions.
13
- - Output supports Indonesian and English.
14
- - Likely secrets are redacted before generation.
15
- - The generated result is Markdown with frontmatter.
 
 
 
 
 
 
 
 
 
 
16
  """
17
 
18
- SAMPLE_TRANSCRIPT = """The key product decision was to keep the core generator reusable while making the hackathon demo Gradio-first.
19
- We also added a Codex Track plan, GitHub-to-Hugging Face sync plan, and post-MVP extension PRDs for WordPress and Modal featured images.
20
  """
21
 
22
- SAMPLE_DIFF = """diff --git a/README.md b/README.md
23
- + Added OpenAI Codex Track requirements.
24
- + Added Hugging Face Space deployment plan.
25
- + Added editorial quality pass to the roadmap.
 
26
  """
27
 
28
- SAMPLE_VERIFICATION = """Documentation baseline was committed with a Codex co-author trailer.
29
- The repo now includes PRDs, AGENTS.md, and Space deployment planning.
 
 
 
30
  """
31
 
32
 
@@ -81,13 +96,13 @@ def generate(
81
 
82
  def load_sample() -> tuple[str, str, str, str, str, str, str, str, bool, bool, bool]:
83
  return (
84
- "Mengubah Sesi Vibe Coding Menjadi Catatan Lapangan",
85
  SAMPLE_SUMMARY,
86
  SAMPLE_TRANSCRIPT,
87
  SAMPLE_DIFF,
88
  SAMPLE_VERIFICATION,
89
- "id",
90
- "reflective",
91
  "developers",
92
  True,
93
  True,
@@ -121,11 +136,17 @@ with gr.Blocks(
121
 
122
  with gr.Row():
123
  with gr.Column(scale=5):
124
- topic = gr.Textbox(label="Topic or working title", placeholder="Building Vibe2Blog for the Build Small Hackathon")
 
 
 
125
  session_summary = gr.Textbox(
126
  label="Session summary or raw agent transcript",
127
  lines=8,
128
- placeholder="Paste a short summary or raw Claude Code/Codex-style session transcript...",
 
 
 
129
  )
130
  with gr.Accordion("Optional context", open=False):
131
  transcript_excerpt = gr.Textbox(label="Transcript excerpt", lines=5)
@@ -136,7 +157,16 @@ with gr.Blocks(
136
  tone = gr.Dropdown(
137
  ["reflective", "tutorial", "concise", "narrative"],
138
  value="reflective",
139
- label="Tone",
 
 
 
 
 
 
 
 
 
140
  )
141
  audience = gr.Dropdown(
142
  ["developers", "beginners", "maintainers", "product stakeholders"],
@@ -146,7 +176,7 @@ with gr.Blocks(
146
  include_code_snippets = gr.Checkbox(value=True, label="Include selected code snippets")
147
  include_frontmatter = gr.Checkbox(value=True, label="Include YAML frontmatter")
148
  editorial_quality_pass = gr.Checkbox(value=True, label="Editorial quality pass")
149
- sample_button = gr.Button("Load sample data", variant="secondary")
150
  generate_button = gr.Button("Generate Markdown", variant="primary")
151
  gr.Markdown(
152
  "Do not paste secrets. Vibe2Blog redacts common token patterns and can summarize raw agent transcripts before building the prompt.",
 
2
 
3
  import gradio as gr
4
 
5
+ from vibe2blog.modal_polisher import should_polish_with_modal
6
  from vibe2blog.pipeline import generate_article
7
 
8
 
9
+ MODAL_STORYTELLING_ENABLED = should_polish_with_modal()
10
+
11
+ SAMPLE_TITLE = "Fixing a Login Button That Stayed Disabled"
12
+
13
+ SAMPLE_SUMMARY = """> The login button stays disabled even after users enter a valid email and password.
14
+
15
+ ⏺ I'll inspect the login form state and validation logic.
16
+ ⏺ Read(src/screens/LoginScreen.tsx)
17
+ ⎿ Read 120 lines
18
+ ⏺ I found the issue. The form validates email and password correctly, but the disabled state reads from an old `isValid` flag that is never updated after typing.
19
+ ⏺ Search(pattern: "isValid", path: "src/screens")
20
+ ⎿ Found 3 matches
21
+ ⏺ Update(src/screens/LoginScreen.tsx)
22
+ ⎿ Updated src/screens/LoginScreen.tsx with 4 additions and 2 removals
23
+
24
+ The fix removes the stale `isValid` state and derives `canSubmit` directly from the current email, password, loading state, and validation errors.
25
+
26
+ > It works now. The button enables after valid input and disables while the login request is running.
27
  """
28
 
29
+ SAMPLE_TRANSCRIPT = """The session focused on a common UI bug: the visual form looked valid, but the submit button stayed disabled.
30
+ The useful debugging step was comparing what the UI displayed with which state variable actually controlled the button.
31
  """
32
 
33
+ SAMPLE_DIFF = """diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx
34
+ - const [isValid, setIsValid] = useState(false)
35
+ - const disabled = !isValid || isLoading
36
+ + const canSubmit = emailError === "" && passwordError === "" && email !== "" && password !== ""
37
+ + const disabled = !canSubmit || isLoading
38
  """
39
 
40
+ SAMPLE_VERIFICATION = """Manual test:
41
+ - Empty form keeps the login button disabled.
42
+ - Invalid email keeps the button disabled and shows an error.
43
+ - Valid email and password enable the button.
44
+ - Clicking login disables the button while the request is pending.
45
  """
46
 
47
 
 
96
 
97
  def load_sample() -> tuple[str, str, str, str, str, str, str, str, bool, bool, bool]:
98
  return (
99
+ SAMPLE_TITLE,
100
  SAMPLE_SUMMARY,
101
  SAMPLE_TRANSCRIPT,
102
  SAMPLE_DIFF,
103
  SAMPLE_VERIFICATION,
104
+ "en",
105
+ "narrative",
106
  "developers",
107
  True,
108
  True,
 
136
 
137
  with gr.Row():
138
  with gr.Column(scale=5):
139
+ topic = gr.Textbox(
140
+ label="Topic or working title",
141
+ placeholder="Example: Fixing a Login Button That Stayed Disabled",
142
+ )
143
  session_summary = gr.Textbox(
144
  label="Session summary or raw agent transcript",
145
  lines=8,
146
+ placeholder=(
147
+ "Paste a summary or raw agent transcript. Example: the problem, what the agent inspected, "
148
+ "what changed, how it was verified, and the final outcome."
149
+ ),
150
  )
151
  with gr.Accordion("Optional context", open=False):
152
  transcript_excerpt = gr.Textbox(label="Transcript excerpt", lines=5)
 
157
  tone = gr.Dropdown(
158
  ["reflective", "tutorial", "concise", "narrative"],
159
  value="reflective",
160
+ label="Tone (fallback mode only)",
161
+ visible=not MODAL_STORYTELLING_ENABLED,
162
+ )
163
+ gr.Markdown(
164
+ (
165
+ "Modal storytelling mode is active. Tone presets are disabled so the model can shape a more natural engineering story from the supplied context."
166
+ if MODAL_STORYTELLING_ENABLED
167
+ else "Tone presets are used by the deterministic fallback when Modal storytelling mode is not enabled."
168
+ ),
169
+ elem_classes=["vibe2blog-note"],
170
  )
171
  audience = gr.Dropdown(
172
  ["developers", "beginners", "maintainers", "product stakeholders"],
 
176
  include_code_snippets = gr.Checkbox(value=True, label="Include selected code snippets")
177
  include_frontmatter = gr.Checkbox(value=True, label="Include YAML frontmatter")
178
  editorial_quality_pass = gr.Checkbox(value=True, label="Editorial quality pass")
179
+ sample_button = gr.Button("Load demo template", variant="secondary")
180
  generate_button = gr.Button("Generate Markdown", variant="primary")
181
  gr.Markdown(
182
  "Do not paste secrets. Vibe2Blog redacts common token patterns and can summarize raw agent transcripts before building the prompt.",
docs/hackathon-submission-plan.md CHANGED
@@ -65,11 +65,12 @@ Build a Hugging Face Space with a Gradio app that supports:
65
  - Optional git diff input.
66
  - Optional verification notes input.
67
  - Language selection: Indonesian (`id`) and English (`en`).
68
- - Tone selection.
69
  - Audience selection.
70
  - Secret redaction before prompt construction.
71
  - Markdown generation with YAML frontmatter.
72
  - Editorial quality pass to reduce generic AI prose and improve specificity.
 
73
  - Markdown preview/editor.
74
  - Copy or download `.md`.
75
  - Built-in sample data for judges.
@@ -121,6 +122,10 @@ The implementation should keep the model behind an `ArticleGenerator` boundary s
121
 
122
  Transcript extraction can optionally use Modal credits through an OpenAI-compatible vLLM service. The Space should read `MODAL_VLLM_BASE_URL` and `MODAL_VLLM_MODEL`; if unavailable or failing, it must fall back to deterministic local extraction so the demo remains usable.
123
 
 
 
 
 
124
  Candidate model families to evaluate:
125
 
126
  - OpenBMB MiniCPM family for lightweight text/multimodal options.
 
65
  - Optional git diff input.
66
  - Optional verification notes input.
67
  - Language selection: Indonesian (`id`) and English (`en`).
68
+ - Tone selection for fallback mode; Modal-backed storytelling mode should hide or ignore tone presets.
69
  - Audience selection.
70
  - Secret redaction before prompt construction.
71
  - Markdown generation with YAML frontmatter.
72
  - Editorial quality pass to reduce generic AI prose and improve specificity.
73
+ - Optional Modal-backed editorial polishing for more natural Markdown drafts.
74
  - Markdown preview/editor.
75
  - Copy or download `.md`.
76
  - Built-in sample data for judges.
 
122
 
123
  Transcript extraction can optionally use Modal credits through an OpenAI-compatible vLLM service. The Space should read `MODAL_VLLM_BASE_URL` and `MODAL_VLLM_MODEL`; if unavailable or failing, it must fall back to deterministic local extraction so the demo remains usable.
124
 
125
+ Editorial polishing can use the same Modal endpoint when `MODAL_POLISH_ENABLED=true`. This should run after article generation and before validation/export. If Modal fails, the original draft should remain valid and downloadable.
126
+
127
+ When Modal-backed polishing is enabled, the demo should emphasize natural storytelling over visible tone presets. The model may add light connective prose, but it must stay inside the provided coding-session context.
128
+
129
  Candidate model families to evaluate:
130
 
131
  - OpenBMB MiniCPM family for lightweight text/multimodal options.
docs/prd-blog-from-vibe-coding.md CHANGED
@@ -75,6 +75,7 @@ Produk utama untuk hackathon adalah Gradio app yang di-host sebagai Hugging Face
75
  - The app will support `id` and `en` as first-class initial output languages.
76
  - The language option will control article prose, section headings, generated title, generated description, and language-related frontmatter.
77
  - The app will expose tone and audience controls as simple dropdowns.
 
78
  - The app will produce Markdown with YAML frontmatter by default.
79
  - The default generated structure will include title, date, tags, introduction, problem context, implementation story, key technical decisions, verification, lessons learned, and conclusion.
80
  - The model used for the hackathon must fit the Build Small constraint of less than or equal to 32B parameters.
@@ -93,6 +94,10 @@ Produk utama untuk hackathon adalah Gradio app yang di-host sebagai Hugging Face
93
  - An `EditorialQualityPass` module will evaluate and optionally rewrite the draft for specificity, narrative flow, concrete technical detail, natural transitions, and reduced generic AI phrasing.
94
  - A `MarkdownExporter` module will produce downloadable Markdown bytes and a deterministic filename.
95
  - The MVP will use prompting, few-shot examples, style guidance, and rewrite passes for humanized writing quality.
 
 
 
 
96
  - Fine-tuning is not required for the hackathon MVP.
97
  - Fine-tuning may be considered later if the project accumulates enough high-quality example articles and needs a consistent personal/editorial voice.
98
  - CLI and slash-command support are future extensions. They should reuse the same core modules after the Gradio MVP works.
 
75
  - The app will support `id` and `en` as first-class initial output languages.
76
  - The language option will control article prose, section headings, generated title, generated description, and language-related frontmatter.
77
  - The app will expose tone and audience controls as simple dropdowns.
78
+ - Tone presets apply to deterministic fallback generation. When Modal-backed storytelling polish is enabled, tone presets are ignored so the model can shape a more natural technical story.
79
  - The app will produce Markdown with YAML frontmatter by default.
80
  - The default generated structure will include title, date, tags, introduction, problem context, implementation story, key technical decisions, verification, lessons learned, and conclusion.
81
  - The model used for the hackathon must fit the Build Small constraint of less than or equal to 32B parameters.
 
94
  - An `EditorialQualityPass` module will evaluate and optionally rewrite the draft for specificity, narrative flow, concrete technical detail, natural transitions, and reduced generic AI phrasing.
95
  - A `MarkdownExporter` module will produce downloadable Markdown bytes and a deterministic filename.
96
  - The MVP will use prompting, few-shot examples, style guidance, and rewrite passes for humanized writing quality.
97
+ - The editorial rewrite pass may use a Modal-hosted OpenAI-compatible vLLM endpoint when `MODAL_POLISH_ENABLED=true` and `MODAL_VLLM_BASE_URL` is configured.
98
+ - Modal polishing must preserve factual claims, Markdown structure, YAML frontmatter, code blocks, file names, commands, and verification results.
99
+ - Modal polishing may add light transitions and connective sentences as long as they stay within the supplied context.
100
+ - If Modal polishing fails, the app must keep the original generated draft rather than failing the user flow.
101
  - Fine-tuning is not required for the hackathon MVP.
102
  - Fine-tuning may be considered later if the project accumulates enough high-quality example articles and needs a consistent personal/editorial voice.
103
  - CLI and slash-command support are future extensions. They should reuse the same core modules after the Gradio MVP works.
requirements.txt CHANGED
@@ -1,3 +1,3 @@
1
  gradio>=5.0,<6
2
  huggingface_hub>=0.23
3
-
 
1
  gradio>=5.0,<6
2
  huggingface_hub>=0.23
3
+ certifi>=2024.0.0
tests/test_generator_tones.py CHANGED
@@ -78,6 +78,20 @@ class ToneTemplateTest(unittest.TestCase):
78
  self.assertNotIn("token", markdown)
79
  self.assertNotIn("role", markdown)
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  if __name__ == "__main__":
83
  unittest.main()
 
78
  self.assertNotIn("token", markdown)
79
  self.assertNotIn("role", markdown)
80
 
81
+ def test_storytelling_internal_tone_uses_narrative_seed_structure(self) -> None:
82
+ result = generate_article(
83
+ topic="Fixing a Login Button That Stayed Disabled",
84
+ session_summary="The login button used stale validity state and stayed disabled after valid input.",
85
+ language="en",
86
+ tone="human storytelling technical blog",
87
+ include_frontmatter=False,
88
+ )
89
+ markdown = result.generation.markdown
90
+
91
+ self.assertIn("## Opening", markdown)
92
+ self.assertIn("## Finding the Root Cause", markdown)
93
+ self.assertNotIn("## Goal", markdown)
94
+
95
 
96
  if __name__ == "__main__":
97
  unittest.main()
tests/test_modal_extractor.py CHANGED
@@ -2,11 +2,14 @@ from __future__ import annotations
2
 
3
  import json
4
  import unittest
 
 
5
 
6
  from vibe2blog.modal_extractor import (
7
  build_chat_completions_url,
8
  build_system_prompt,
9
  parse_chat_completion_content,
 
10
  )
11
 
12
 
@@ -33,6 +36,36 @@ class ModalExtractorTest(unittest.TestCase):
33
 
34
  self.assertEqual(parse_chat_completion_content(body), "Initial problem: fixed")
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  if __name__ == "__main__":
38
  unittest.main()
 
2
 
3
  import json
4
  import unittest
5
+ import urllib.error
6
+ from unittest.mock import MagicMock, patch
7
 
8
  from vibe2blog.modal_extractor import (
9
  build_chat_completions_url,
10
  build_system_prompt,
11
  parse_chat_completion_content,
12
+ post_json_with_modal_redirect,
13
  )
14
 
15
 
 
36
 
37
  self.assertEqual(parse_chat_completion_content(body), "Initial problem: fixed")
38
 
39
+ def test_post_json_preserves_post_body_across_modal_303_redirect(self) -> None:
40
+ redirect = urllib.error.HTTPError(
41
+ "https://example.modal.run/v1/chat/completions",
42
+ 303,
43
+ "See Other",
44
+ {"Location": "https://example.modal.run/call-id/v1/chat/completions"},
45
+ None,
46
+ )
47
+ final = MagicMock()
48
+ final.__enter__.return_value.read.return_value = b'{"ok": true}'
49
+ final.__exit__.return_value = False
50
+ opener = MagicMock()
51
+ opener.open.side_effect = [redirect, final]
52
+
53
+ with patch("vibe2blog.modal_extractor.urllib.request.build_opener", return_value=opener):
54
+ body = post_json_with_modal_redirect(
55
+ "https://example.modal.run/v1/chat/completions",
56
+ {"model": "llm"},
57
+ {"Content-Type": "application/json"},
58
+ timeout=10,
59
+ )
60
+
61
+ self.assertEqual(body, '{"ok": true}')
62
+ first_request = opener.open.call_args_list[0].args[0]
63
+ second_request = opener.open.call_args_list[1].args[0]
64
+ self.assertEqual(first_request.get_method(), "POST")
65
+ self.assertEqual(second_request.get_method(), "POST")
66
+ self.assertEqual(first_request.data, second_request.data)
67
+ self.assertEqual(second_request.full_url, "https://example.modal.run/call-id/v1/chat/completions")
68
+
69
 
70
  if __name__ == "__main__":
71
  unittest.main()
tests/test_modal_polisher.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ from vibe2blog.context import normalize_context
8
+ from vibe2blog.modal_polisher import (
9
+ STORYTELLING_TONE,
10
+ build_polish_system_prompt,
11
+ build_polish_user_prompt,
12
+ maybe_polish_markdown,
13
+ modal_polish_enabled,
14
+ polish_markdown_with_modal,
15
+ should_polish_with_modal,
16
+ )
17
+
18
+
19
+ class ModalPolisherTest(unittest.TestCase):
20
+ def test_modal_polish_is_disabled_by_default(self) -> None:
21
+ with patch.dict(os.environ, {}, clear=True):
22
+ self.assertFalse(modal_polish_enabled())
23
+ self.assertFalse(should_polish_with_modal())
24
+
25
+ def test_modal_polish_requires_flag_and_base_url(self) -> None:
26
+ with patch.dict(
27
+ os.environ,
28
+ {
29
+ "MODAL_POLISH_ENABLED": "true",
30
+ "MODAL_VLLM_BASE_URL": "https://example.modal.run",
31
+ },
32
+ clear=True,
33
+ ):
34
+ self.assertTrue(modal_polish_enabled())
35
+ self.assertTrue(should_polish_with_modal())
36
+
37
+ def test_indonesian_prompt_preserves_facts_and_markdown(self) -> None:
38
+ prompt = build_polish_system_prompt("id")
39
+
40
+ self.assertIn("Pertahankan semua klaim faktual", prompt)
41
+ self.assertIn("YAML frontmatter", prompt)
42
+ self.assertIn("code block", prompt)
43
+ self.assertIn("Jangan membuat YAML frontmatter", prompt)
44
+ self.assertIn("Jangan mengarang", prompt)
45
+ self.assertIn("Jangan memberi contoh", prompt)
46
+ self.assertIn("cerita teknis", prompt)
47
+ self.assertIn("kalimat penghubung ringan", prompt)
48
+
49
+ def test_user_prompt_includes_style_context(self) -> None:
50
+ context = normalize_context(
51
+ session_summary="We fixed a bug.",
52
+ language="en",
53
+ tone=STORYTELLING_TONE,
54
+ audience="maintainers",
55
+ )
56
+
57
+ prompt = build_polish_user_prompt("## Draft", context)
58
+
59
+ self.assertIn("Language: en", prompt)
60
+ self.assertIn(f"Editorial mode: {STORYTELLING_TONE}", prompt)
61
+ self.assertIn("Audience: maintainers", prompt)
62
+ self.assertIn("Return the edited version of this exact draft only.", prompt)
63
+ self.assertIn("## Draft", prompt)
64
+
65
+ def test_polish_markdown_calls_openai_compatible_endpoint(self) -> None:
66
+ context = normalize_context(session_summary="We fixed a bug.", language="en")
67
+ response_body = (
68
+ '{"choices":[{"message":{"content":"---\\ntitle: \\"Draft\\"\\n---'
69
+ '\\n\\n## Problem\\nPolished draft."}}]}'
70
+ )
71
+
72
+ with patch.dict(
73
+ os.environ,
74
+ {
75
+ "MODAL_VLLM_BASE_URL": "https://example.modal.run",
76
+ "MODAL_VLLM_MODEL": "llm",
77
+ "MODAL_POLISH_TIMEOUT": "12",
78
+ },
79
+ clear=True,
80
+ ):
81
+ with patch("vibe2blog.modal_polisher.post_json_with_modal_redirect", return_value=response_body) as post:
82
+ polished = polish_markdown_with_modal("## Problem\nDraft.", context)
83
+
84
+ self.assertIn("Polished draft", polished)
85
+ endpoint, payload, headers = post.call_args.args
86
+ self.assertEqual(endpoint, "https://example.modal.run/v1/chat/completions")
87
+ self.assertEqual(payload["model"], "llm")
88
+ self.assertEqual(payload["temperature"], 0.35)
89
+ self.assertEqual(headers["Content-Type"], "application/json")
90
+
91
+ def test_maybe_polish_falls_back_to_original_when_modal_fails(self) -> None:
92
+ context = normalize_context(session_summary="We fixed a bug.", language="en")
93
+ original = "## Problem\nOriginal draft."
94
+
95
+ with patch.dict(
96
+ os.environ,
97
+ {
98
+ "MODAL_POLISH_ENABLED": "true",
99
+ "MODAL_VLLM_BASE_URL": "https://example.modal.run",
100
+ },
101
+ clear=True,
102
+ ):
103
+ with patch("vibe2blog.modal_polisher.polish_markdown_with_modal", return_value=""):
104
+ self.assertEqual(maybe_polish_markdown(original, context), original)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ unittest.main()
tests/test_pipeline.py CHANGED
@@ -2,10 +2,46 @@ from __future__ import annotations
2
 
3
  import tempfile
4
  import unittest
 
5
 
 
 
 
6
  from vibe2blog.pipeline import generate_article
7
 
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  class ArticlePipelineTest(unittest.TestCase):
10
  def test_pipeline_generates_downloadable_markdown(self) -> None:
11
  with tempfile.TemporaryDirectory() as tmpdir:
@@ -46,6 +82,55 @@ class ArticlePipelineTest(unittest.TestCase):
46
  self.assertNotIn("⏺", markdown)
47
  self.assertTrue(result.validation.valid)
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  if __name__ == "__main__":
51
  unittest.main()
 
2
 
3
  import tempfile
4
  import unittest
5
+ from unittest.mock import patch
6
 
7
+ from vibe2blog.context import SessionContext
8
+ from vibe2blog.generator import ArticleGenerator, GenerationResult
9
+ from vibe2blog.modal_polisher import STORYTELLING_TONE
10
  from vibe2blog.pipeline import generate_article
11
 
12
 
13
+ class CapturingGenerator(ArticleGenerator):
14
+ markdown = """---
15
+ title: "Captured"
16
+ ---
17
+
18
+ ## Problem
19
+ Draft.
20
+
21
+ ## Context
22
+ Context.
23
+
24
+ ## Change
25
+ Change.
26
+
27
+ ## Verification
28
+ Verification.
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self.context: SessionContext | None = None
33
+
34
+ def generate(self, context: SessionContext) -> GenerationResult:
35
+ self.context = context
36
+ return GenerationResult(
37
+ markdown=self.markdown,
38
+ title="Captured",
39
+ quality_notes="Captured.",
40
+ prompt="",
41
+ provider="capturing",
42
+ )
43
+
44
+
45
  class ArticlePipelineTest(unittest.TestCase):
46
  def test_pipeline_generates_downloadable_markdown(self) -> None:
47
  with tempfile.TemporaryDirectory() as tmpdir:
 
82
  self.assertNotIn("⏺", markdown)
83
  self.assertTrue(result.validation.valid)
84
 
85
+ def test_pipeline_applies_modal_polish_when_configured(self) -> None:
86
+ polished = """---
87
+ title: "Vibe2Blog"
88
+ ---
89
+
90
+ ## Problem
91
+ Polished draft.
92
+
93
+ ## Context
94
+ Specific context.
95
+
96
+ ## Change
97
+ Specific change.
98
+
99
+ ## Verification
100
+ Specific verification.
101
+ """
102
+
103
+ with patch("vibe2blog.pipeline.should_polish_with_modal", return_value=True):
104
+ with patch("vibe2blog.pipeline.maybe_polish_markdown", return_value=polished):
105
+ result = generate_article(
106
+ topic="Vibe2Blog",
107
+ session_summary="We built a Gradio app for turning coding sessions into Markdown.",
108
+ language="en",
109
+ )
110
+
111
+ self.assertIn("Polished draft", result.generation.markdown)
112
+ self.assertIn("+modal-polish", result.generation.provider)
113
+ self.assertIn("Modal polish applied.", result.generation.quality_notes)
114
+ self.assertTrue(result.validation.valid)
115
+
116
+ def test_modal_storytelling_ignores_user_tone_before_generation(self) -> None:
117
+ generator = CapturingGenerator()
118
+
119
+ with patch("vibe2blog.pipeline.should_polish_with_modal", return_value=True):
120
+ with patch("vibe2blog.pipeline.maybe_polish_markdown", return_value=CapturingGenerator.markdown):
121
+ result = generate_article(
122
+ generator=generator,
123
+ topic="Fix login button",
124
+ session_summary="The user selected tutorial, but Modal should own the editorial style.",
125
+ language="en",
126
+ tone="tutorial",
127
+ )
128
+
129
+ self.assertIsNotNone(generator.context)
130
+ self.assertEqual(generator.context.tone, STORYTELLING_TONE)
131
+ self.assertNotIn("+modal-polish", result.generation.provider)
132
+ self.assertTrue(result.validation.valid)
133
+
134
 
135
  if __name__ == "__main__":
136
  unittest.main()
vibe2blog/generator.py CHANGED
@@ -117,14 +117,14 @@ def build_template_article(context: SessionContext) -> str:
117
  return build_indonesian_tutorial_template(context, title)
118
  if tone == "concise":
119
  return build_indonesian_concise_template(context, title)
120
- if tone == "narrative":
121
  return build_indonesian_narrative_template(context, title)
122
  return build_indonesian_reflective_template(context, title)
123
  if tone == "tutorial":
124
  return build_english_tutorial_template(context, title)
125
  if tone == "concise":
126
  return build_english_concise_template(context, title)
127
- if tone == "narrative":
128
  return build_english_narrative_template(context, title)
129
  return build_english_reflective_template(context, title)
130
 
 
117
  return build_indonesian_tutorial_template(context, title)
118
  if tone == "concise":
119
  return build_indonesian_concise_template(context, title)
120
+ if tone == "narrative" or "storytelling" in tone:
121
  return build_indonesian_narrative_template(context, title)
122
  return build_indonesian_reflective_template(context, title)
123
  if tone == "tutorial":
124
  return build_english_tutorial_template(context, title)
125
  if tone == "concise":
126
  return build_english_concise_template(context, title)
127
+ if tone == "narrative" or "storytelling" in tone:
128
  return build_english_narrative_template(context, title)
129
  return build_english_reflective_template(context, title)
130
 
vibe2blog/modal_extractor.py CHANGED
@@ -2,7 +2,9 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
 
5
  import urllib.error
 
6
  import urllib.request
7
 
8
 
@@ -48,21 +50,61 @@ def summarize_with_modal_vllm(raw_text: str, *, language: str) -> str:
48
  if api_key:
49
  headers["Authorization"] = f"Bearer {api_key}"
50
 
51
- request = urllib.request.Request(
52
- endpoint,
53
- data=json.dumps(payload).encode("utf-8"),
54
- headers=headers,
55
- method="POST",
56
- )
57
  try:
58
- with urllib.request.urlopen(request, timeout=timeout) as response:
59
- body = response.read().decode("utf-8")
60
  except (OSError, urllib.error.HTTPError, urllib.error.URLError):
61
  return ""
62
 
63
  return parse_chat_completion_content(body)
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def build_chat_completions_url(base_url: str) -> str:
67
  """Normalize a Modal web server URL into the OpenAI chat completions route."""
68
  trimmed = base_url.rstrip("/")
 
2
 
3
  import json
4
  import os
5
+ import ssl
6
  import urllib.error
7
+ import urllib.parse
8
  import urllib.request
9
 
10
 
 
50
  if api_key:
51
  headers["Authorization"] = f"Bearer {api_key}"
52
 
 
 
 
 
 
 
53
  try:
54
+ body = post_json_with_modal_redirect(endpoint, payload, headers, timeout=timeout)
 
55
  except (OSError, urllib.error.HTTPError, urllib.error.URLError):
56
  return ""
57
 
58
  return parse_chat_completion_content(body)
59
 
60
 
61
+ class NoPostRedirectHandler(urllib.request.HTTPRedirectHandler):
62
+ """Expose Modal redirects so callers can repeat POST with the same body."""
63
+
64
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
65
+ return None
66
+
67
+
68
+ def post_json_with_modal_redirect(
69
+ endpoint: str,
70
+ payload: dict,
71
+ headers: dict[str, str],
72
+ *,
73
+ timeout: float,
74
+ max_redirects: int = 3,
75
+ ) -> str:
76
+ """POST JSON and manually follow Modal 303 redirects without changing to GET."""
77
+ data = json.dumps(payload).encode("utf-8")
78
+ opener = build_modal_opener()
79
+ url = endpoint
80
+ for _ in range(max_redirects + 1):
81
+ request = urllib.request.Request(url, data=data, headers=headers, method="POST")
82
+ try:
83
+ with opener.open(request, timeout=timeout) as response:
84
+ return response.read().decode("utf-8")
85
+ except urllib.error.HTTPError as exc:
86
+ if exc.code not in {301, 302, 303, 307, 308}:
87
+ raise
88
+ location = exc.headers.get("Location")
89
+ if not location:
90
+ raise
91
+ url = urllib.parse.urljoin(url, location)
92
+ return ""
93
+
94
+
95
+ def build_modal_opener() -> urllib.request.OpenerDirector:
96
+ """Build an opener that understands Modal redirects and local CA bundles."""
97
+ handlers: list[object] = [NoPostRedirectHandler]
98
+ try:
99
+ import certifi
100
+ except ImportError:
101
+ pass
102
+ else:
103
+ context = ssl.create_default_context(cafile=certifi.where())
104
+ handlers.append(urllib.request.HTTPSHandler(context=context))
105
+ return urllib.request.build_opener(*handlers)
106
+
107
+
108
  def build_chat_completions_url(base_url: str) -> str:
109
  """Normalize a Modal web server URL into the OpenAI chat completions route."""
110
  trimmed = base_url.rstrip("/")
vibe2blog/modal_polisher.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import urllib.error
5
+
6
+ from .context import SessionContext
7
+ from .modal_extractor import (
8
+ DEFAULT_MODAL_MODEL,
9
+ build_chat_completions_url,
10
+ parse_chat_completion_content,
11
+ post_json_with_modal_redirect,
12
+ )
13
+
14
+
15
+ STORYTELLING_TONE = "human storytelling technical blog"
16
+
17
+
18
+ def modal_polish_enabled() -> bool:
19
+ """Enable the polish pass only when the user explicitly opts in."""
20
+ enabled = os.getenv("MODAL_POLISH_ENABLED", "false").strip().lower()
21
+ return enabled in {"1", "true", "yes", "on"}
22
+
23
+
24
+ def should_polish_with_modal() -> bool:
25
+ """Check both the feature flag and the shared Modal vLLM endpoint config."""
26
+ return modal_polish_enabled() and bool(os.getenv("MODAL_VLLM_BASE_URL", "").strip())
27
+
28
+
29
+ def polish_markdown_with_modal(markdown: str, context: SessionContext) -> str:
30
+ """Rewrite a Markdown draft through Modal vLLM, returning blank on failure."""
31
+ base_url = os.getenv("MODAL_VLLM_BASE_URL", "").strip()
32
+ if not base_url:
33
+ return ""
34
+
35
+ model = os.getenv("MODAL_VLLM_MODEL", DEFAULT_MODAL_MODEL).strip() or DEFAULT_MODAL_MODEL
36
+ timeout = float(os.getenv("MODAL_POLISH_TIMEOUT", "60"))
37
+ endpoint = build_chat_completions_url(base_url)
38
+ payload = {
39
+ "model": model,
40
+ "messages": [
41
+ {"role": "system", "content": build_polish_system_prompt(context.language)},
42
+ {"role": "user", "content": build_polish_user_prompt(markdown, context)},
43
+ ],
44
+ "temperature": 0.35,
45
+ "top_p": 0.9,
46
+ "max_tokens": 1800,
47
+ }
48
+ headers = {
49
+ "Content-Type": "application/json",
50
+ "Accept": "application/json",
51
+ }
52
+ api_key = os.getenv("MODAL_VLLM_API_KEY", "").strip()
53
+ if api_key:
54
+ headers["Authorization"] = f"Bearer {api_key}"
55
+
56
+ try:
57
+ body = post_json_with_modal_redirect(endpoint, payload, headers, timeout=timeout)
58
+ except (OSError, urllib.error.HTTPError, urllib.error.URLError):
59
+ return ""
60
+
61
+ return parse_chat_completion_content(body)
62
+
63
+
64
+ def build_polish_system_prompt(language: str) -> str:
65
+ """Constrain Modal to editorial polish, not new article generation."""
66
+ if language == "id":
67
+ return (
68
+ "Kamu adalah editor teknis untuk Vibe2Blog. "
69
+ "Poles draft Markdown agar terasa lebih natural, spesifik, dan manusiawi. "
70
+ "Pertahankan semua klaim faktual, struktur Markdown, YAML frontmatter, heading, "
71
+ "nama file, command, hasil verifikasi, dan code block. "
72
+ "Jangan membuat YAML frontmatter, tanggal, author, tag, atau metadata baru jika tidak ada di draft. "
73
+ "Jangan mengarang test, file, API, keputusan, hasil, atau detail baru. "
74
+ "Hilangkan frasa AI generik dan perbaiki alur transisi. "
75
+ "Susun ulang draft sebagai cerita teknis yang enak diikuti: masalah, penelusuran, momen temuan, perubahan, verifikasi, dan pelajaran. "
76
+ "Boleh menambahkan transisi atau kalimat penghubung ringan selama tetap berada dalam konteks yang diberikan. "
77
+ "Jika draft pendek, tetap poles draft yang ada tanpa meminta konteks tambahan. "
78
+ "Jangan memberi contoh, template, penjelasan, atau catatan editor. "
79
+ "Kembalikan hanya Markdown final."
80
+ )
81
+ return (
82
+ "You are a technical editor for Vibe2Blog. "
83
+ "Polish the Markdown draft so it reads more natural, specific, and human. "
84
+ "Preserve all factual claims, Markdown structure, YAML frontmatter, headings, "
85
+ "file names, commands, verification results, and code blocks. "
86
+ "Do not create YAML frontmatter, dates, authors, tags, or metadata when the draft does not already include them. "
87
+ "Do not invent tests, files, APIs, decisions, outcomes, or new details. "
88
+ "Remove generic AI phrasing and improve narrative flow. "
89
+ "Shape the draft as a technical story: problem, investigation, discovery moment, change, verification, and lesson. "
90
+ "You may add light transitions or connective sentences as long as they stay inside the supplied context. "
91
+ "If the draft is short, polish only the draft provided without asking for more context. "
92
+ "Do not provide examples, templates, explanations, or editor notes. "
93
+ "Return only the final Markdown."
94
+ )
95
+
96
+
97
+ def build_polish_user_prompt(markdown: str, context: SessionContext) -> str:
98
+ """Provide style context and the bounded draft to rewrite."""
99
+ return f"""Language: {context.language}
100
+ Editorial mode: {context.tone}
101
+ Audience: {context.audience}
102
+
103
+ Rewrite this Markdown draft according to the system instructions.
104
+ Return the edited version of this exact draft only.
105
+
106
+ Draft:
107
+ {markdown[:16000]}
108
+ """
109
+
110
+
111
+ def maybe_polish_markdown(markdown: str, context: SessionContext) -> str:
112
+ """Apply Modal polish when configured, otherwise return the original draft."""
113
+ if not should_polish_with_modal():
114
+ return markdown
115
+ polished = polish_markdown_with_modal(markdown, context)
116
+ return polished or markdown
vibe2blog/pipeline.py CHANGED
@@ -6,6 +6,7 @@ from .context import SessionContext, normalize_context
6
  from .exporter import write_markdown
7
  from .extractor import maybe_extract_session_summary
8
  from .generator import ArticleGenerator, GenerationResult, get_default_generator
 
9
  from .redactor import redact_secrets
10
  from .validator import ValidationResult, validate_markdown
11
 
@@ -40,6 +41,23 @@ def build_context_with_redaction(**kwargs: object) -> SessionContext:
40
  )
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def generate_article(
44
  *,
45
  generator: ArticleGenerator | None = None,
@@ -48,8 +66,20 @@ def generate_article(
48
  ) -> PipelineResult:
49
  """Run the full article flow: context prep, generation, validation, and export."""
50
  context = build_context_with_redaction(**kwargs)
 
 
51
  active_generator = generator or get_default_generator()
52
- generation = active_generator.generate(context)
 
 
 
 
 
 
 
 
 
 
53
  validation = validate_markdown(
54
  generation.markdown,
55
  require_frontmatter=context.include_frontmatter,
 
6
  from .exporter import write_markdown
7
  from .extractor import maybe_extract_session_summary
8
  from .generator import ArticleGenerator, GenerationResult, get_default_generator
9
+ from .modal_polisher import STORYTELLING_TONE, should_polish_with_modal, maybe_polish_markdown
10
  from .redactor import redact_secrets
11
  from .validator import ValidationResult, validate_markdown
12
 
 
41
  )
42
 
43
 
44
+ def build_storytelling_context(context: SessionContext) -> SessionContext:
45
+ """Use one editorial mode for Modal-backed human storytelling output."""
46
+ return SessionContext(
47
+ topic=context.topic,
48
+ session_summary=context.session_summary,
49
+ transcript_excerpt=context.transcript_excerpt,
50
+ git_diff=context.git_diff,
51
+ verification_notes=context.verification_notes,
52
+ language=context.language,
53
+ tone=STORYTELLING_TONE,
54
+ audience=context.audience,
55
+ include_code_snippets=context.include_code_snippets,
56
+ include_frontmatter=context.include_frontmatter,
57
+ editorial_quality_pass=context.editorial_quality_pass,
58
+ )
59
+
60
+
61
  def generate_article(
62
  *,
63
  generator: ArticleGenerator | None = None,
 
66
  ) -> PipelineResult:
67
  """Run the full article flow: context prep, generation, validation, and export."""
68
  context = build_context_with_redaction(**kwargs)
69
+ modal_storytelling = should_polish_with_modal()
70
+ generation_context = build_storytelling_context(context) if modal_storytelling else context
71
  active_generator = generator or get_default_generator()
72
+ generation = active_generator.generate(generation_context)
73
+ if modal_storytelling:
74
+ polished_markdown = maybe_polish_markdown(generation.markdown, generation_context)
75
+ if polished_markdown != generation.markdown:
76
+ generation = GenerationResult(
77
+ markdown=polished_markdown,
78
+ title=generation.title,
79
+ quality_notes=f"{generation.quality_notes} Modal polish applied.",
80
+ prompt=generation.prompt,
81
+ provider=f"{generation.provider}+modal-polish",
82
+ )
83
  validation = validate_markdown(
84
  generation.markdown,
85
  require_frontmatter=context.include_frontmatter,