Multi-Model QA
File:src/cofounder_agent/modules/content/multi_model_qa.py
Tested by: src/cofounder_agent/tests/unit/services/test_multi_model_qa.py
Last reviewed: 2026-06-22
What it does
MultiModelQA.review() runs a draft through several reviewers — a
deterministic programmatic validator, an LLM critic that uses a
different model than the writer, and a fan of opt-in gates (citation
verifier, topic-delivery, internal-consistency, image relevance, web
fact-check, URL verification, rendered-preview screenshot). Each
reviewer returns a ReviewerResult; the aggregator weighs the scores,
applies validator-warning penalties, and decides approved=True/False
based on a configurable threshold (default qa_final_score_threshold=70).
The point is adversarial coverage. Different reviewers have different
blind spots: regex catches what LLMs miss, a Claude critic catches what
local Ollama misses, web fact-check catches post-cutoff product claims
the LLM critic would otherwise reject as fabricated. The aggregator
trusts the score over individual approved booleans because critics
nitpick approval status more than they nitpick the number.
Public API
MultiModelQA(pool, settings_service=None)— constructor.await qa.review(title, content, topic="", research_sources=None, preview_url=None) -> MultiModelResult— full review, returns the aggregated decision.MultiModelResult.approved: bool— final decision.MultiModelResult.final_score: float— weighted-average score (0-100).MultiModelResult.reviews: list[ReviewerResult]— per-reviewer detail.MultiModelResult.summary: str— single-line human summary.MultiModelResult.format_feedback_text(max_chars=4000) -> str— human-readable critique for the approval UI; lands inpipeline_tasks.qa_feedbackandpipeline_versions.qa_feedback.format_qa_feedback_from_reviews(qa_reviews, final_score, approved, max_chars)— module-level helper for callers that hold the serialized review dicts (e.g. finalize) without reconstructing theMultiModelResult.ReviewerResult(reviewer, approved, score, feedback, provider)— per-reviewer record;provideris one ofprogrammatic,ollama,anthropic,consistency_gate,vision_gate,web_factcheck,url_verifier,http_head.
Configuration
All settings come fromapp_settings via the injected settings_service.
Aggregation:
qa_validator_weight(default0.4) — programmatic validator weight.qa_critic_weight(default0.6) — Ollama/Anthropic/Google critic weight.qa_gate_weight(default0.3) — weight for consistency, vision, web fact-check, URL gates.qa_final_score_threshold(default70) — approval cut-off.qa_consistency_veto_threshold(default50) — consistency gate is advisory unless its score drops below this.content_validator_warning_qa_penalty(default3points/warning) — per-warning penalty applied to the final aggregated score (GH-91).
pipeline_critic_model(defaultgemma3:27b) — primary critic.qa_fallback_critic_model(defaultgemma3:27b) — fallback when the primary returns empty or errors.qa_thinking_model_max_tokens(default8000) — used for thinking models likeglm-4.7,qwen3:30b.qa_standard_max_tokens(default1500) — non-thinking models.qa_temperature(default0.3) — both critic and gates.electricity_rate_kwh— local critic cost telemetry input.
qa_web_factcheck_enabled(defaulttrue) — DuckDuckGo verification of product/spec claims.qa_web_factcheck_match_ratio(default0.6) — fraction of a claim’s key terms that must appear in the search snippets to mark it VERIFIED.qa_web_factcheck_num_results/_snippet_chars/_min_term_len/_max_claims(defaults3/500/2/3) — search breadth and the claim-matching heuristics (previously hardcoded literals in the rail).qa_citation_verify_enabled(defaulttrue) — HTTP HEAD for cited URLs.qa_citation_max_dead_ratio(default0.30).qa_citation_min_count(default0).qa_citation_timeout_seconds(default8.0).qa_vision_check_enabled(defaultfalse) — inline image relevance via vision model (~10s/image).qa_vision_model(defaultqwen3-vl:30b).qa_vision_max_images(default3).qa_vision_pass_threshold(default60).qa_vision_num_predict(default1024) — output-token budget for the vision legs (shared by image-relevance + rendered-preview).qa_vision_thinking_num_predict(default8000) — the larger budget a thinking vision model (e.g.qwen3-vl) gets so its<think>trace does not exhaust the budget before the JSON scores are emitted.qa_preview_screenshot_enabled(defaultfalse) — full-page screenshot via Playwright + vision review.qa_preview_vision_model(defaultqwen3-vl:30b).qa_preview_pass_threshold(default70).qa_preview_viewport_width/height(defaults1280×1024).qa_gate_max_tokens/qa_gate_timeout_seconds(defaults600/60).site_url,site_domain— used by URL verifier to distinguish external citations from self-links.
Dependencies
- Reads from:
modules.content.content_validator.validate_contentandverify_content_urls(programmatic layer).services.citation_verifier(HTTP HEAD path).services.web_research.WebResearcher(DuckDuckGo fact check).services.preview_screenshot.capture_preview_screenshot(Playwright).services.ollama_client.OllamaClient(deliberately concrete — it exposesconfigure_electricity+check_healthfeatures the Provider Protocol does not).services.cost_lookupfor per-token cost; the critic model is thepipeline_critic_modelper-step pin (thecost_tier.*resolution was removed in PR #1907 — it had replaced the deletedservices.model_router.get_model_routerafter the 2026-05-08 Phase 2 cleanup). See../cost-tier-routing.md.
- Writes to:
audit_logviaaudit_log_bgforcritic_fallbackevents.- Cost rows return through the caller (the
qa.*rail atoms that replaced thecross_model_qastage in #355) which persists them tocost_logs.
- External APIs:
- Local Ollama HTTP (critic, gates, vision, web-factcheck post-processing).
- Outbound HTTP HEAD/GET for citation + URL verification + image download.
- DuckDuckGo via
WebResearcher.
Failure modes
- Programmatic validator critical issue (non-fact) — short-circuits
the entire review. Returns
approved=False. Diagnose viavalidation.issues. The only “critical” exemption isknown_wrong_fact, which gets a second chance from the web fact-check gate. - Ollama unreachable / health check times out — critic + every gate
silently skip.
approvedfalls back to validator-only score. Visible in logs as[MULTI_QA] Ollama not availableorhealth check timed out after 5s. Watch thecritic_fallbackaudit event for primary-critic failures that triggered the fallback chain. - Critic returns unparseable JSON — logged as warning with first
200 chars; reviewer skipped. If both primary and fallback
unparseable,
critic_skipped=Trueand the final score uses the validator only. - Dead links / fabricated URLs —
url_verifierreturnsapproved=False, score=max(0, 100 - 20*dead_count). Hard-blocks publish. - Vision model OOM / timeout — gates return
None, no veto applied. Increaseqa_gate_timeout_secondsor disable the gate. - Vision gate “passed open” (
vision_scorer_unavailablefinding) — theqa.visionatom failed open (didn’t block) because neither the image-relevance nor the rendered-preview leg produced a scoreable verdict. This does not mean the vision model is down: on the observed runs (2026-07-12) it acquired the GPU and spent thousands of tokens, then the leg still returned no scores. Root cause:qwen3-vlis a thinking model whose<think>trace shares thenum_predictbudget with the JSON answer; at the 1024 base the trace exhausts the budget before the scores are emitted, so the leg returnsNone. Fixed by_maybe_bump_vision_thinking_budget, which raises the budget toqa_vision_thinking_num_predict(default 8000) for thinking vision models, mirroring the text critic. Every no-verdict path also logs a specific[VISION_QA] …WARNING (shipped to Loki) naming the cause (unreachable model, unparseable/empty response, unreadable images, missing handle). Defense-in-depth: the cost-logging pool falls back tosite_config._poolvia the sharedresolve_poolhelper (with a loud[POOL]warning) when the threadeddatabase_servicecarries no live pool — so a genuine handle gap is caught, not silent. NB: the earlier “pool is None” diagnosis was acost_logsNULL-task_idartifact (the dispatch did happen). Triage: read the[VISION_QA]/[POOL]WARNING for the run, not just the page. - Web fact-check rate-limited — DuckDuckGo errors are caught and
logged as
[WEB_FACTCHECK] Failed (non-fatal). Reviewer skipped.
Common ops
- Soften the critic for a misbehaving niche: raise
qa_final_score_threshold(e.g. to 60) or lowerqa_critic_weight. - Swap critic model:
poindexter settings set pipeline_critic_model ollama/glm-4.7(oranthropic/claude-haiku-4-5for cloud — note cost guard interactions). - Disable vision QA:
qa_vision_check_enabled=false(default). - Enable rendered-preview gate (after Playwright install):
qa_preview_screenshot_enabled=trueand ensure the calling stage passespreview_url=/preview/{hash}. - Audit recent critic fallbacks:
SELECT created_at, payload FROM audit_log WHERE event_type = 'critic_fallback' ORDER BY created_at DESC LIMIT 50; - Read the full feedback for a specific task:
SELECT qa_feedback FROM pipeline_tasks WHERE task_id = '<uuid>';
See also
docs/architecture/anti-hallucination.md— full reviewer catalogue + pipeline ordering.docs/architecture/services/content_router_service.md— the calling pipeline.project_qa_critic_cutoff(operator design note) — why the web fact-check gate exists.project_multi_model_qa(operator design note) — design vision for adversarial multi-model review.