Content Validator
File:src/cofounder_agent/modules/content/content_validator.py
Tested by: src/cofounder_agent/tests/unit/services/test_content_validator.py
Last reviewed: 2026-04-30
What it does
validate_content(title, content, topic, tags) runs deterministic
regex rules against generated content and returns a
ValidationResult with a list of ValidationIssue records, a
passed: bool, and a score_penalty: int. No LLM calls — this is the
fast hard-rules layer that catches the patterns LLMs reliably get
wrong: fabricated people, made-up statistics, hallucinated citations,
fake personal anecdotes, leaked image-prompt artifacts, contradictions
of brand-known facts.
A ValidationResult.passed is False if ANY issue is severity = "critical". Warnings drop the score (-3 each), criticals drop it
harder (-10 each) and block publish.
The validator is one of three QA layers:
- This file — programmatic regex (no LLM).
quality_service.py— heuristic + LLM-based scoring.multi_model_qa.py— adversarial multi-reviewer aggregator that consumes this validator’sValidationResultas one of its inputs.
verify_content_urls(content) does HTTP HEAD
against every URL in the content (plus a no-citations check) and
returns its own list[ValidationIssue] — kept out of validate_content
so the sync path stays sync.
Public API
validate_content(title, content, topic="", tags=None) -> ValidationResult— the single sync entry point. Runs ALL rules in order, then applies severity-promotion (see below), then returns.await verify_content_urls(content) -> list[ValidationIssue]— async URL liveness check. Skips internal links (domains listed inapp_settings.site_domains) and emitsdead_link(HTTP 4xx/5xx),slow_link(timeout),unresolvable_link(other exceptions), and a singleno_citationswarning if the content has zero external URLs.ValidationResult(passed, issues, score_penalty)— dataclass. Properties:critical_count,warning_count.ValidationIssue(severity, category, description, matched_text, line_number=0)— dataclass.severityis"critical"or"warning".categoryvalues are documented per-rule below.CONTENT_VALIDATOR_WARNINGS_TOTAL— Prometheus counter (content_validator_warnings_total{rule=...}), incremented per warning category emitted (GH-91). Falls back to a no-op shim ifprometheus_clientisn’t installed.- Module-level pattern lists (exposed but generally not callable from
outside):
FAKE_NAME_PATTERNS,FAKE_STAT_PATTERNS,COMPANY_IMPOSSIBLE(aliasGLAD_LABS_IMPOSSIBLE),FAKE_QUOTE_PATTERNS,FABRICATED_EXPERIENCE_PATTERNS,HALLUCINATED_LINK_PATTERNS,UNLINKED_CITATION_PATTERNS,BRAND_CONTRADICTION_PATTERNS,LEAKED_IMAGE_PROMPT_PATTERNS,IMAGE_PLACEHOLDER_PATTERNS,FILLER_PHRASE_PATTERNS.
Rule categories
| Category | Severity | Notes |
|---|---|---|
fake_person | critical | Sarah/John-style names + role suffix |
fake_stat | critical | ”%-reduction”, McKinsey-style citations |
company_claim | critical | Impossible claims about company size/age |
fake_quote | critical | Quoted dialogue + attribution to invented person |
fabricated_experience | critical | ”I sat down with…”, “at my company” |
hallucinated_link | critical | ”our guide on X”, “see our post” |
image_placeholder | critical | [IMAGE-1: ...], [FIGURE: ...] |
truncated_content | critical | Doesn’t end with sentence punctuation |
unlinked_citation | warning* | Promoted to critical when source is named without URL |
hallucinated_reference | warning* | Library/API reference not in stdlib/PyPI/Ollama lists |
code_block_density | warning | Tech-tagged post with insufficient code |
brand_contradiction | warning | ”OpenAI API pricing”, “AWS bill” |
leaked_image_prompt | warning | *A split-screen comparison...* italics |
known_wrong_fact | DB-driven | Severity per fact_overrides.severity |
filler_phrase | warning | ”many organizations have found”, etc. |
banned_header | warning | ”## Introduction”, ”## Conclusion”, etc. |
filler_intro | warning | ”In this post,…”, “In today’s fast-paced…” |
late_acronym_expansion | warning | ”CRM (Customer Relationship Management)” after 2+ uses |
title_diversity | warning | ”Beyond the…”, “Mastering…”, “Unlocking…” |
dead_link | critical | HTTP 4xx/5xx (from verify_content_urls) |
slow_link | warning | Timed out (from verify_content_urls) |
unresolvable_link | warning | Network/DNS failure (from verify_content_urls) |
no_citations | warning | Zero external URLs (from verify_content_urls) |
Configuration
Most rules are pattern-driven and not individually tunable, but the following live inapp_settings via services.site_config:
Company facts (loaded once at module import time — restart required
to pick up changes):
company_name(default"My Company")company_founded_date(default"2025-01-01")company_founded_year(default2025)company_age_months(default12) — used by title-year-claim rulecompany_team_size(default1)company_founder_name(default"Founder")company_products(default empty, comma-separated)
content_validator_warning_reject_threshold(default3) — when ANY single warning category exceeds this count, every warning in that category is promoted to critical. Set to0to disable.
code_density_check_enabled(defaultTrue)code_density_tag_filter(default"technical,ai,programming,ml,python,javascript,rust,go")code_density_min_blocks_per_700w(default1)code_density_min_line_ratio_pct(default20)code_density_long_post_floor_words(default300) — line-ratio rule only fires above this word count.
verify_content_urls):
site_domains(comma-separated, no default — operators bring their own brand) — domains to skip in liveness checks, plus the no-citations counting uses this to identify external vs internal URLs.localhostis always added.
fact_overridestable —(pattern, correct_fact, severity, active). Cached in-process for_FACT_OVERRIDES_TTL = 300seconds. Manage with pgAdmin or the API; no redeploy needed for new entries to take effect.
brain/hallucination-check/, located via
ancestor walk + /opt/poindexter/brain/hallucination-check fallback):
stdlib-python-312.txt— Python 3.12 stdlib module namespypi-top-500.txt— top 500 PyPI packagesollama-models.txt— known Ollama model nameslibrary-topics.json—{library: [topic, ...]}for topic-coherence cross-check
Dependencies
- Reads from:
services.site_config— company facts + tunables.fact_overridestable — DB-driven known-wrong-fact list.brain.bootstrap.resolve_database_url()— DSN resolver for the fact_overrides loader (noos.getenvin services).- Disk lists under
brain/hallucination-check/. prometheus_client(optional) for the warnings counter.
- Writes to:
CONTENT_VALIDATOR_WARNINGS_TOTALPrometheus counter (incremented BEFORE severity promotion, so Grafana sees raw warning volume).
- External APIs:
verify_content_urlsdoes outbound HTTP HEAD (User-AgentMozilla/5.0 (compatible; Poindexter-LinkChecker/1.0), 10s timeout, follows redirects).
- Sister-service callers:
modules.content.multi_model_qa— wrapsvalidate_contentas theprogrammatic_validatorreviewer; callsverify_content_urlswhenqa_citation_verify_enabledis set.modules.content.ai_content_generator— uses validation in its refinement loop (legacy generation path).
Failure modes
- Severity promotion (a) — per-category threshold (GH-91): if any
warning category fires more than
content_validator_warning_reject_thresholdtimes in one post, every warning in that category is promoted to critical. Description is suffixed with(promoted: N <category> warnings exceeds reject threshold of T). The original warning categories are still emitted to Prometheus before promotion. - Severity promotion (b) — named-source-without-URL (GH-91): any
individual
unlinked_citationwarning whose matched text contains one ofmedium,article,blog post,documentation,paper,study, AND has nohttps?://URL within ~100 chars after the match, is promoted to critical with description “Named source without accompanying URL (hallucinated attribution): …”. Fires per-issue regardless of category count. fact_overridesDB lookup fails — caught, logged at warning with[VALIDATOR] fact_overrides DB load failed (using cache): .... Falls back to whatever’s in_fact_overrides_cache(may be empty on a cold start). Validation continues without the DB-driven rules.- Prometheus counter unavailable — module-level shim
(
_NoopCounter) used at import time ifprometheus_clientis missing. Counter calls become no-ops; nothing else breaks. brain/hallucination-check/*.txtmissing —_load_known_listlogs warning, returns empty set._is_known_referencethen returns False for everything, which would normally flag every backticked identifier as hallucinated — but the_HALLUCINATION_WHITELISTand the strict reference-shape patterns keep noise manageable. Still, missing data files degrade the quality of this rule significantly. Watch the import-time log.library-topics.jsonmissing or unparseable — logged, returns{}. Topic-coherence cross-check is then skipped (libraries that ARE recognized always pass).- Truncation false-positive on legit list-ending posts — the
detector ignores lines starting with code fences, headings, or
list markers, so most cases are handled. Posts that legitimately
end on a quote or parenthesis also pass (the closing-bracket set
includes
",',),”,’). - Company name with regex metacharacters — the rules use
re.escape(_COMPANY_NAME)so brand names with./+/*etc. don’t blow up the patterns.
Common ops
- Add a new known-wrong fact (no redeploy):
Picks up within
_FACT_OVERRIDES_TTL(300s) on the next call. - Tune the per-category warning threshold (more lenient):
poindexter settings set content_validator_warning_reject_threshold 5 - Disable the code-density rule for a specific niche — drop the
niche’s tags from
code_density_tag_filter, orpoindexter settings set code_density_check_enabled false. - Inspect which rules are firing in production —
sum by (rule)(rate(content_validator_warnings_total[1h]))in Grafana. (The counter is emitted pre-promotion so it shows raw signal, not blocking decisions.) - Run validation against a draft locally:
- Refresh hallucination-check lists — edit the files under
brain/hallucination-check/, then restart the worker (the in-process cache is module-level and never expires).
See also
docs/architecture/anti-hallucination.md— three-layer defense (prompts, programmatic validator, multi-model QA).docs/architecture/services/multi_model_qa.md— how this validator feeds the adversarial aggregator.docs/architecture/services/quality_service.md— companion scoring layer (heuristic + LLM, vs this validator’s pure-regex hard rules).brain/hallucination-check/README.md(if present) — manage the stdlib/PyPI/Ollama and library-topics data files.feedback_no_silent_defaults(operator design note) — why criticals must hard-block.