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