feat: create topic-aware analysis prompt template (#92)

- Template with topic context placeholders (TOPIC_NAME, TOPIC_DESCRIPTION, TOPIC_ID) - Conditional blocks (IF_TOPIC/IF_NO_TOPIC) for backward compatibility - Per-topic wisdom injection from topics/{id}/wisdom.md - Render script reads from squadscope.topic.yml (zero external deps) - Domain-expert editorial stance calibrated per topic - 17 tests covering topic/no-topic/missing-wisdom scenarios Closes #63 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 19, 2026 at 15:36 UTC 62ca90b836c95168609cda7674981a93ebf36a66
4 files changed +570
.squad/agents/farnsworth/history.md
+1
@@ -24,3 +24,4 @@
24 - **2026-05-19T11:48:44.543Z:** PR #55 opened with TechCrunch integration proposal. Analysis specification decisions finalized and merged into `.squad/decisions.md`: frontmatter superset contract, five stable H2 sections, required Signal/Noise/Gaps subsections, honest degradation of trending when momentum data incomplete. Learned state injection framework (wisdom.md + skills/) integrated into decision log; weekly analyzer will read these at prompt-render time starting Phase 2.
25 - **2026-05-19T11:55:46Z:** Self-review of TechCrunch RSS PRD (PR #55) completed. Key findings: (1) PRD file missing from branch — blocker. (2) Correlation hit rate realistically 5–15%, not the implicit "most articles correlate" assumption. Name-matching across TC articles and GitHub repos requires entity resolution, not string matching. (3) Filtering is underspecified — no keywords, no category selection, no confidence tiers defined. (4) Temporal mismatch between real-time RSS and weekly analysis means correlations are retrospective explanations, not predictive signals — the "prediction enhancement" phase is premature. (5) No success criteria defined to evaluate whether integration justifies its complexity. Lesson: excitement about architectural patterns (plugin system) must not override skepticism about editorial value-add. The right question isn't "can we?" but "should we, and will it measurably improve output?"
26 - **2026-05-19T15:08:00Z:** Leela milestone decomposition complete. Issues assigned to v0.5–v0.9 milestones. Scribe logged orchestration and merged decision. Your assigned v0.5 analysis and synthesis issues are ready. See `.squad/orchestration-log/2026-05-19T15-08-leela.md` for full decomposition outcome.
27 +- **2026-05-19T15:22:00+02:00:** Topic-aware prompt template implemented (Issue #63). Key architecture decisions: (1) Used `{{#IF_TOPIC}}`/`{{#IF_NO_TOPIC}}` conditional blocks rather than Jinja2 to keep the template readable as standalone markdown and avoid adding template engine dependencies. (2) Wisdom injection is two-tier — global wisdom from `.squad/identity/wisdom.md` (existing) plus per-topic wisdom from `topics/{id}/wisdom.md` (new). (3) Render script (`scripts/render_topic_prompt.py`) is zero-dependency (stdlib only, with optional PyYAML), so it works in any CI environment without pip install. (4) Backward compatibility guaranteed: when no `squadscope.topic.yml` exists, the template collapses cleanly to general-mode analysis identical to the existing `analyze-weekly.md` behavior.
prompts/analyze-topic.md new
+228
@@ -0,0 +1,228 @@
1 +# Topic-Aware Weekly Analysis Prompt Template
2 +
3 +You are Farnsworth, the analyst for SquadScope.
4 +
5 +Your job is to turn one weekly crawler artifact into a structured editorial summary for publication.
6 +
7 +## Topic Context
8 +
9 +{{#IF_TOPIC}}
10 +You are analyzing GitHub activity for the **{{TOPIC_NAME}}** topic channel.
11 +Focus area: {{TOPIC_DESCRIPTION}}
12 +
13 +When analyzing repos in this domain, apply the editorial stance of a domain expert.
14 +A significant project in {{TOPIC_NAME}} means it demonstrates genuine technical depth,
15 +solves a real practitioner problem, or represents a meaningful shift in how the community
16 +builds within this domain. Hype without substance deserves extra skepticism here — domain
17 +experts notice when a project is wrapping existing tools with a marketing layer.
18 +
19 +### Topic-Specific Quality Expectations
20 +
21 +- Repos must be **relevant to {{TOPIC_NAME}}** — tangential projects get mentioned only if they have cross-domain implications.
22 +- Trending assessment should weight **domain-specific signals** (e.g., adoption by known practitioners, integration with established toolchains) over raw star counts.
23 +- Gap analysis should specifically identify what **{{TOPIC_NAME}} practitioners** are missing this week.
24 +- The editorial voice should sound like a senior engineer who works in {{TOPIC_NAME}} daily, not a generalist summarizer.
25 +{{/IF_TOPIC}}
26 +{{#IF_NO_TOPIC}}
27 +No topic channel configured. Producing general-purpose analysis across all domains.
28 +Apply broad technical judgment without domain-specific weighting.
29 +{{/IF_NO_TOPIC}}
30 +
31 +## Inputs
32 +
33 +- Current datetime: `{{CURRENT_DATETIME}}`
34 +- Raw weekly JSON path: `{{RAW_JSON_PATH}}`
35 +- Output path: `{{OUTPUT_PATH}}`
36 +- Previous summary path: `{{PREVIOUS_SUMMARY_PATH_OR_NONE}}`
37 +
38 +### Raw weekly JSON
39 +
40 +```json
41 +{{RAW_JSON_CONTENT}}
42 +```
43 +
44 +### Previous weekly summary
45 +
46 +Use this only if it is provided. If it is missing, unavailable, or empty, say so briefly in the analysis where relevant and do not invent continuity.
47 +
48 +```md
49 +{{PREVIOUS_SUMMARY_CONTENT_OR_EMPTY}}
50 +```
51 +
52 +## Learned context
53 +
54 +The analyze job must resolve both learned-state placeholders before invoking Copilot CLI or the GitHub Models fallback.
55 +
56 +1. Read `.squad/identity/wisdom.md` and inject its current contents into `{{WISDOM}}`.
57 +2. Read markdown files under `.squad/skills/` (for example `SKILL.md` files in nested skill folders), concatenate them in a stable sorted order, and inject that bundle into `{{SKILLS}}`.
58 +3. If either source is missing or empty, inject a short explicit note rather than leaving the placeholder unresolved.
59 +4. Treat learned context as guidance that sharpens judgment, not as permission to ignore the current week's evidence.
60 +
61 +### Wisdom
62 +
63 +{{WISDOM}}
64 +
65 +### Skills
66 +
67 +{{SKILLS}}
68 +
69 +## Per-Topic Wisdom
70 +
71 +{{#IF_TOPIC}}
72 +The following topic-specific wisdom was accumulated from previous analysis cycles for **{{TOPIC_NAME}}**.
73 +Apply it as calibration for editorial judgment — it encodes lessons about what matters in this domain,
74 +common pitfalls, and quality patterns specific to {{TOPIC_NAME}} projects.
75 +
76 +{{WISDOM_CONTENT}}
77 +
78 +If per-topic wisdom is empty or unavailable, rely on general domain knowledge and the global wisdom above.
79 +{{/IF_TOPIC}}
80 +{{#IF_NO_TOPIC}}
81 +No per-topic wisdom available. Rely on global wisdom and skills above.
82 +{{/IF_NO_TOPIC}}
83 +
84 +## Objective
85 +
86 +Write the full contents of `{{OUTPUT_PATH}}` as markdown with YAML frontmatter. The file must conform to the Output Contract in `docs/analysis-spec.md` exactly.
87 +
88 +## Editorial stance
89 +
90 +Be critical, selective, and opinionated.
91 +
92 +- Do **not** just list repositories.
93 +- Do **not** mistake popularity for momentum.
94 +- Do **not** praise obvious hype without evidence.
95 +- Do **call out** noise, weak substance, exploit-heavy churn, and missing categories.
96 +- Do **explain why** the week matters.
97 +{{#IF_TOPIC}}
98 +- Do **filter through the lens of {{TOPIC_NAME}}** — general-interest repos that don't touch this domain can be mentioned briefly but shouldn't anchor the narrative.
99 +- Do **compare against domain norms** — what's impressive in {{TOPIC_NAME}} specifically, not just GitHub overall.
100 +{{/IF_TOPIC}}
101 +
102 +## Analysis dimensions to apply
103 +
104 +1. **Importance Assessment** — identify what solves real problems or signals durable technical movement.
105 +2. **Trend Detection** — connect multiple repos or topics into patterns; compare against the prior week when available.
106 +3. **Hype Detection** — separate substantial projects from wrappers, clones, marketing-heavy launches, or low-signal attention.
107 +4. **Gap Analysis** — explicitly identify what is missing or underrepresented.
108 +5. **Context** — explain whether this week continues, sharpens, or breaks from recent movement.
109 +{{#IF_TOPIC}}
110 +6. **Domain Calibration** — apply {{TOPIC_NAME}}-specific knowledge to distinguish genuinely important work from projects that merely touch this space.
111 +{{/IF_TOPIC}}
112 +
113 +## Hard rules
114 +
115 +1. Use the raw JSON as the primary evidence source.
116 +2. Ignore unknown JSON fields.
117 +3. If `trending_repos[*].stars_gained` is mostly missing or null, explicitly say the trending section is directionally useful but not a true momentum leaderboard yet.
118 +4. Use `signals.top_topics` as supporting evidence, not as a substitute for judgment.
119 +5. Frontmatter must include exactly these keys:
120 + - `title`
121 + - `date`
122 + - `week`
123 + - `year`
124 + - `tags`
125 + - `categories`
126 + - `repos_featured`
127 + - `stars_tracked`
128 + - `top_repo`
129 + - `quality_score`
130 + - `summary`
131 +{{#IF_TOPIC}}
132 + - `topic` (value: `{{TOPIC_ID}}`)
133 +{{/IF_TOPIC}}
134 +6. `date` must be `{{CURRENT_DATETIME}}`.
135 +7. `tags` must contain 3-8 topical items.
136 +8. `categories` must include `weekly`.
137 +9. `repos_featured` should equal the total number of repos considered in the weekly editorial pass.
138 +10. `stars_tracked` should equal the total stars across those repos.
139 +11. `top_repo` should be the repo that best anchors the editorial narrative, not automatically the most-starred repo.
140 +12. `quality_score` must be an honest 0-100 self-assessment; publishable work is `>= 60`.
141 +13. Include all required sections in this exact order:
142 +
143 +```md
144 +## Notable New Repositories
145 +
146 +## Trending This Week
147 +
148 +## Trend Analysis
149 +### Signal
150 +### Noise
151 +
152 +## What's Missing
153 +### Gaps
154 +
155 +## Conclusion
156 +```
157 +
158 +14. Keep the section scope aligned with the spec:
159 + - `## Notable New Repositories`: ~120-220 words, curating 3-7 repos.
160 + - `## Trending This Week`: ~100-180 words; explain where attention moved and add the stars-gained caveat when data is missing.
161 + - `## Trend Analysis`: ~150-260 words total across `### Signal` and `### Noise`.
162 + - `## What's Missing`: ~80-160 words with 2-4 concrete blind spots under `### Gaps`.
163 + - `## Conclusion`: ~50-110 words focused on why the week matters and what to watch next.
164 +15. The body must be at least 200 words.
165 +16. Do not include raw JSON, notes to self, placeholders, or tool transcripts.
166 +17. Every repository reference in the body must be a clickable GitHub markdown link in this exact format: `[owner/repo](https://github.com/owner/repo)`.
167 +18. Output only the finished markdown file content.
168 +
169 +## Working method
170 +
171 +1. Identify the strongest new-repo signals.
172 +2. Evaluate the trending set for real momentum versus incumbent popularity.
173 +3. Cluster themes across repos and topics.
174 +4. Name one or more overhyped or low-signal patterns.
175 +5. Identify concrete gaps or absences.
176 +6. Compare with the previous week if a previous summary was provided.
177 +7. Apply relevant wisdom and skills where they clarify the call, but overrule them when the raw evidence says they do not fit this week.
178 +{{#IF_TOPIC}}
179 +8. Apply per-topic wisdom for domain-calibrated judgment.
180 +9. Filter the narrative through the {{TOPIC_NAME}} lens — lead with domain-relevant items.
181 +{{/IF_TOPIC}}
182 +8. Produce a concise, readable editorial summary that a technical reader would actually trust.
183 +
184 +## Output template
185 +
186 +```md
187 +---
188 +title: "Week NN, YYYY Analysis"
189 +date: {{CURRENT_DATETIME}}
190 +week: "YYYY-WNN"
191 +year: YYYY
192 +tags: [tag-1, tag-2, tag-3]
193 +categories: [weekly]
194 +repos_featured: 0
195 +stars_tracked: 0
196 +top_repo: "owner/repo"
197 +quality_score: 0
198 +summary: "One-sentence editorial thesis."
199 +---
200 +
201 +## Notable New Repositories
202 +
203 +Write 1-2 paragraphs that curate the most credible new launches. Whenever you mention a repo, use `[owner/repo](https://github.com/owner/repo)`.
204 +
205 +## Trending This Week
206 +
207 +Write 1 paragraph about where attention moved. If star deltas are missing, say so clearly. Whenever you mention a repo, use `[owner/repo](https://github.com/owner/repo)`.
208 +
209 +## Trend Analysis
210 +
211 +### Signal
212 +
213 +Write 1 paragraph on the durable patterns. Whenever you mention a repo, use `[owner/repo](https://github.com/owner/repo)`.
214 +
215 +### Noise
216 +
217 +Write 1 paragraph on the inflated, weak, or off-mission patterns. Whenever you mention a repo, use `[owner/repo](https://github.com/owner/repo)`.
218 +
219 +## What's Missing
220 +
221 +### Gaps
222 +
223 +Write 1 paragraph on meaningful absences or underserved categories. Whenever you mention a repo, use `[owner/repo](https://github.com/owner/repo)`.
224 +
225 +## Conclusion
226 +
227 +Write a short closing takeaway about what the week means and what to watch next. Whenever you mention a repo, use `[owner/repo](https://github.com/owner/repo)`.
228 +```
scripts/render_topic_prompt.py new
+178
@@ -0,0 +1,178 @@
1 +#!/usr/bin/env python3
2 +"""Render the topic-aware analysis prompt template with values from squadscope.topic.yml.
3 +
4 +Usage:
5 + python scripts/render_topic_prompt.py [--config PATH] [--output PATH]
6 +
7 +If --config is not provided, looks for squadscope.topic.yml in the repo root.
8 +If --output is not provided, prints to stdout.
9 +"""
10 +
11 +from __future__ import annotations
12 +
13 +import argparse
14 +import os
15 +import sys
16 +from pathlib import Path
17 +
18 +# Use PyYAML if available, otherwise fall back to a minimal inline parser
19 +try:
20 + import yaml # type: ignore[import-untyped]
21 +
22 + def _load_yaml(path: Path) -> dict:
23 + with open(path) as f:
24 + return yaml.safe_load(f) or {}
25 +
26 +except ImportError:
27 + # Minimal YAML subset parser for simple key: value files
28 + def _load_yaml(path: Path) -> dict: # type: ignore[misc]
29 + result: dict = {}
30 + with open(path) as f:
31 + for line in f:
32 + line = line.strip()
33 + if not line or line.startswith("#"):
34 + continue
35 + if ":" in line:
36 + key, _, value = line.partition(":")
37 + key = key.strip()
38 + value = value.strip().strip('"').strip("'")
39 + result[key] = value
40 + return result
41 +
42 +
43 +def find_repo_root() -> Path:
44 + """Walk up from CWD to find the git repo root."""
45 + current = Path.cwd()
46 + while current != current.parent:
47 + if (current / ".git").exists():
48 + return current
49 + current = current.parent
50 + return Path.cwd()
51 +
52 +
53 +def load_topic_config(config_path: Path | None) -> dict | None:
54 + """Load topic configuration from YAML. Returns None if not found."""
55 + if config_path and config_path.exists():
56 + return _load_yaml(config_path)
57 +
58 + # Try default location
59 + root = find_repo_root()
60 + default_path = root / "squadscope.topic.yml"
61 + if default_path.exists():
62 + return _load_yaml(default_path)
63 +
64 + return None
65 +
66 +
67 +def load_wisdom(topic_id: str | None) -> str:
68 + """Load per-topic wisdom file if it exists."""
69 + if not topic_id:
70 + return ""
71 +
72 + root = find_repo_root()
73 + wisdom_path = root / "topics" / topic_id / "wisdom.md"
74 + if wisdom_path.exists():
75 + return wisdom_path.read_text(encoding="utf-8").strip()
76 +
77 + return ""
78 +
79 +
80 +def render_template(template: str, topic_config: dict | None) -> str:
81 + """Render the topic-aware prompt template with config values.
82 +
83 + Handles conditional blocks:
84 + {{#IF_TOPIC}}...{{/IF_TOPIC}} — included only when topic config is present
85 + {{#IF_NO_TOPIC}}...{{/IF_NO_TOPIC}} — included only when topic config is absent
86 + """
87 + has_topic = topic_config is not None and bool(topic_config.get("id") or topic_config.get("name"))
88 +
89 + if has_topic:
90 + topic_id = topic_config.get("id", "")
91 + topic_name = topic_config.get("name", "")
92 + topic_description = topic_config.get("description", "")
93 + wisdom_content = load_wisdom(topic_id)
94 +
95 + # Remove IF_NO_TOPIC blocks
96 + rendered = _remove_blocks(template, "IF_NO_TOPIC")
97 + # Keep IF_TOPIC block contents
98 + rendered = _keep_blocks(rendered, "IF_TOPIC")
99 +
100 + # Replace placeholders
101 + rendered = rendered.replace("{{TOPIC_ID}}", topic_id)
102 + rendered = rendered.replace("{{TOPIC_NAME}}", topic_name)
103 + rendered = rendered.replace("{{TOPIC_DESCRIPTION}}", topic_description)
104 + rendered = rendered.replace("{{WISDOM_CONTENT}}", wisdom_content if wisdom_content else "(No per-topic wisdom accumulated yet.)")
105 + else:
106 + # Remove IF_TOPIC blocks
107 + rendered = _remove_blocks(template, "IF_TOPIC")
108 + # Keep IF_NO_TOPIC block contents
109 + rendered = _keep_blocks(rendered, "IF_NO_TOPIC")
110 +
111 + # Clear any remaining topic placeholders
112 + rendered = rendered.replace("{{TOPIC_ID}}", "")
113 + rendered = rendered.replace("{{TOPIC_NAME}}", "")
114 + rendered = rendered.replace("{{TOPIC_DESCRIPTION}}", "")
115 + rendered = rendered.replace("{{WISDOM_CONTENT}}", "")
116 +
117 + return rendered
118 +
119 +
120 +def _remove_blocks(text: str, block_name: str) -> str:
121 + """Remove conditional block markers and their contents."""
122 + start_tag = "{{#" + block_name + "}}"
123 + end_tag = "{{/" + block_name + "}}"
124 +
125 + result = text
126 + while start_tag in result:
127 + start_idx = result.index(start_tag)
128 + end_idx = result.index(end_tag) + len(end_tag)
129 + # Remove trailing newline if present
130 + if end_idx < len(result) and result[end_idx] == "\n":
131 + end_idx += 1
132 + result = result[:start_idx] + result[end_idx:]
133 +
134 + return result
135 +
136 +
137 +def _keep_blocks(text: str, block_name: str) -> str:
138 + """Remove conditional block markers but keep their contents."""
139 + start_tag = "{{#" + block_name + "}}"
140 + end_tag = "{{/" + block_name + "}}"
141 +
142 + result = text.replace(start_tag + "\n", "").replace(start_tag, "")
143 + result = result.replace(end_tag + "\n", "").replace(end_tag, "")
144 + return result
145 +
146 +
147 +def main() -> None:
148 + parser = argparse.ArgumentParser(description="Render topic-aware analysis prompt template")
149 + parser.add_argument("--config", type=Path, help="Path to squadscope.topic.yml")
150 + parser.add_argument("--output", type=Path, help="Output file path (default: stdout)")
151 + parser.add_argument("--template", type=Path, help="Template file (default: prompts/analyze-topic.md)")
152 + args = parser.parse_args()
153 +
154 + root = find_repo_root()
155 +
156 + # Load template
157 + template_path = args.template or (root / "prompts" / "analyze-topic.md")
158 + if not template_path.exists():
159 + print(f"Error: Template not found at {template_path}", file=sys.stderr)
160 + sys.exit(1)
161 + template = template_path.read_text(encoding="utf-8")
162 +
163 + # Load topic config
164 + topic_config = load_topic_config(args.config)
165 +
166 + # Render
167 + rendered = render_template(template, topic_config)
168 +
169 + # Output
170 + if args.output:
171 + args.output.parent.mkdir(parents=True, exist_ok=True)
172 + args.output.write_text(rendered, encoding="utf-8")
173 + else:
174 + print(rendered)
175 +
176 +
177 +if __name__ == "__main__":
178 + main()
tests/test_render_topic_prompt.py new
+163
@@ -0,0 +1,163 @@
1 +"""Tests for scripts/render_topic_prompt.py."""
2 +
3 +from __future__ import annotations
4 +
5 +import sys
6 +from pathlib import Path
7 +from unittest.mock import patch
8 +
9 +import pytest
10 +
11 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
12 +
13 +from scripts.render_topic_prompt import (
14 + load_wisdom,
15 + render_template,
16 + _remove_blocks,
17 + _keep_blocks,
18 +)
19 +
20 +
21 +SAMPLE_TEMPLATE = """\
22 +# Analysis
23 +
24 +{{#IF_TOPIC}}
25 +Topic: {{TOPIC_NAME}}
26 +Description: {{TOPIC_DESCRIPTION}}
27 +ID: {{TOPIC_ID}}
28 +Wisdom: {{WISDOM_CONTENT}}
29 +{{/IF_TOPIC}}
30 +{{#IF_NO_TOPIC}}
31 +General analysis mode.
32 +{{/IF_NO_TOPIC}}
33 +
34 +## Common section
35 +This appears always.
36 +"""
37 +
38 +
39 +class TestRenderTemplateWithTopic:
40 + """Template renders correctly when topic config is provided."""
41 +
42 + def test_topic_placeholders_replaced(self):
43 + config = {"id": "ai-ml", "name": "AI/ML", "description": "Artificial intelligence and machine learning"}
44 + rendered = render_template(SAMPLE_TEMPLATE, config)
45 +
46 + assert "AI/ML" in rendered
47 + assert "Artificial intelligence and machine learning" in rendered
48 + assert "ai-ml" in rendered
49 +
50 + def test_if_topic_block_included(self):
51 + config = {"id": "devops", "name": "DevOps", "description": "CI/CD and infrastructure"}
52 + rendered = render_template(SAMPLE_TEMPLATE, config)
53 +
54 + assert "Topic: DevOps" in rendered
55 + assert "Description: CI/CD and infrastructure" in rendered
56 +
57 + def test_if_no_topic_block_removed(self):
58 + config = {"id": "devops", "name": "DevOps", "description": "CI/CD and infrastructure"}
59 + rendered = render_template(SAMPLE_TEMPLATE, config)
60 +
61 + assert "General analysis mode." not in rendered
62 +
63 + def test_common_section_preserved(self):
64 + config = {"id": "security", "name": "Security", "description": "AppSec and infra security"}
65 + rendered = render_template(SAMPLE_TEMPLATE, config)
66 +
67 + assert "## Common section" in rendered
68 + assert "This appears always." in rendered
69 +
70 + def test_wisdom_placeholder_filled_when_no_wisdom_file(self):
71 + config = {"id": "nonexistent-topic", "name": "Test", "description": "Test topic"}
72 + rendered = render_template(SAMPLE_TEMPLATE, config)
73 +
74 + assert "(No per-topic wisdom accumulated yet.)" in rendered
75 +
76 + def test_wisdom_content_injected(self):
77 + config = {"id": "ai-ml", "name": "AI/ML", "description": "AI stuff"}
78 + wisdom_text = "Look for transformer architectures and real benchmarks."
79 +
80 + with patch("scripts.render_topic_prompt.load_wisdom", return_value=wisdom_text):
81 + rendered = render_template(SAMPLE_TEMPLATE, config)
82 +
83 + assert "Look for transformer architectures" in rendered
84 +
85 + def test_frontmatter_topic_field(self):
86 + template_with_frontmatter = """\
87 +{{#IF_TOPIC}}
88 + - `topic` (value: `{{TOPIC_ID}}`)
89 +{{/IF_TOPIC}}
90 +"""
91 + config = {"id": "rust", "name": "Rust", "description": "Rust ecosystem"}
92 + rendered = render_template(template_with_frontmatter, config)
93 +
94 + assert "`rust`" in rendered
95 +
96 +
97 +class TestRenderTemplateWithoutTopic:
98 + """Template works without topic config (backward compatibility)."""
99 +
100 + def test_none_config_uses_general_mode(self):
101 + rendered = render_template(SAMPLE_TEMPLATE, None)
102 +
103 + assert "General analysis mode." in rendered
104 +
105 + def test_empty_config_uses_general_mode(self):
106 + rendered = render_template(SAMPLE_TEMPLATE, {})
107 +
108 + assert "General analysis mode." in rendered
109 +
110 + def test_topic_blocks_removed(self):
111 + rendered = render_template(SAMPLE_TEMPLATE, None)
112 +
113 + assert "{{TOPIC_NAME}}" not in rendered
114 + assert "{{TOPIC_DESCRIPTION}}" not in rendered
115 + assert "{{TOPIC_ID}}" not in rendered
116 +
117 + def test_common_section_preserved(self):
118 + rendered = render_template(SAMPLE_TEMPLATE, None)
119 +
120 + assert "## Common section" in rendered
121 + assert "This appears always." in rendered
122 +
123 + def test_no_conditional_markers_remain(self):
124 + rendered = render_template(SAMPLE_TEMPLATE, None)
125 +
126 + assert "{{#IF_TOPIC}}" not in rendered
127 + assert "{{/IF_TOPIC}}" not in rendered
128 + assert "{{#IF_NO_TOPIC}}" not in rendered
129 + assert "{{/IF_NO_TOPIC}}" not in rendered
130 +
131 +
132 +class TestLoadWisdom:
133 + """Missing wisdom file handled gracefully."""
134 +
135 + def test_missing_wisdom_returns_empty(self):
136 + result = load_wisdom("topic-that-does-not-exist-xyz")
137 + assert result == ""
138 +
139 + def test_none_topic_id_returns_empty(self):
140 + result = load_wisdom(None)
141 + assert result == ""
142 +
143 + def test_empty_topic_id_returns_empty(self):
144 + result = load_wisdom("")
145 + assert result == ""
146 +
147 +
148 +class TestBlockHelpers:
149 + """Unit tests for block manipulation helpers."""
150 +
151 + def test_remove_blocks(self):
152 + text = "before\n{{#FOO}}\ninner\n{{/FOO}}\nafter"
153 + result = _remove_blocks(text, "FOO")
154 + assert "inner" not in result
155 + assert "before" in result
156 + assert "after" in result
157 +
158 + def test_keep_blocks(self):
159 + text = "before\n{{#FOO}}\ninner\n{{/FOO}}\nafter"
160 + result = _keep_blocks(text, "FOO")
161 + assert "inner" in result
162 + assert "{{#FOO}}" not in result
163 + assert "{{/FOO}}" not in result