Spaces:
Sleeping
Sleeping
feat: use modal storytelling mode
Browse files- README.md +26 -2
- app.py +53 -23
- docs/hackathon-submission-plan.md +6 -1
- docs/prd-blog-from-vibe-coding.md +5 -0
- requirements.txt +1 -1
- tests/test_generator_tones.py +14 -0
- tests/test_modal_extractor.py +33 -0
- tests/test_modal_polisher.py +108 -0
- tests/test_pipeline.py +85 -0
- vibe2blog/generator.py +2 -2
- vibe2blog/modal_extractor.py +50 -8
- vibe2blog/modal_polisher.py +116 -0
- vibe2blog/pipeline.py +31 -1
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://
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
-
SAMPLE_TRANSCRIPT = """The
|
| 19 |
-
|
| 20 |
"""
|
| 21 |
|
| 22 |
-
SAMPLE_DIFF = """diff --git a/
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
+
|
|
|
|
| 26 |
"""
|
| 27 |
|
| 28 |
-
SAMPLE_VERIFICATION = """
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 85 |
SAMPLE_SUMMARY,
|
| 86 |
SAMPLE_TRANSCRIPT,
|
| 87 |
SAMPLE_DIFF,
|
| 88 |
SAMPLE_VERIFICATION,
|
| 89 |
-
"
|
| 90 |
-
"
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 125 |
session_summary = gr.Textbox(
|
| 126 |
label="Session summary or raw agent transcript",
|
| 127 |
lines=8,
|
| 128 |
-
placeholder=
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|