flonat-research 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/domain-reviewer.md +336 -0
- package/.claude/agents/fixer.md +226 -0
- package/.claude/agents/paper-critic.md +370 -0
- package/.claude/agents/peer-reviewer.md +289 -0
- package/.claude/agents/proposal-reviewer.md +215 -0
- package/.claude/agents/referee2-reviewer.md +367 -0
- package/.claude/agents/references/journal-referee-profiles.md +354 -0
- package/.claude/agents/references/paper-critic/council-personas.md +77 -0
- package/.claude/agents/references/paper-critic/council-prompts.md +198 -0
- package/.claude/agents/references/peer-reviewer/report-template.md +199 -0
- package/.claude/agents/references/peer-reviewer/sa-prompts.md +260 -0
- package/.claude/agents/references/peer-reviewer/security-scan.md +188 -0
- package/.claude/agents/references/proposal-reviewer/report-template.md +144 -0
- package/.claude/agents/references/proposal-reviewer/sa-prompts.md +149 -0
- package/.claude/agents/references/referee-config.md +114 -0
- package/.claude/agents/references/referee2-reviewer/audit-checklists.md +287 -0
- package/.claude/agents/references/referee2-reviewer/report-template.md +334 -0
- package/.claude/rules/design-before-results.md +52 -0
- package/.claude/rules/ignore-agents-md.md +17 -0
- package/.claude/rules/ignore-gemini-md.md +17 -0
- package/.claude/rules/lean-claude-md.md +45 -0
- package/.claude/rules/learn-tags.md +99 -0
- package/.claude/rules/overleaf-separation.md +67 -0
- package/.claude/rules/plan-first.md +175 -0
- package/.claude/rules/read-docs-first.md +50 -0
- package/.claude/rules/scope-discipline.md +28 -0
- package/.claude/settings.json +125 -0
- package/.context/current-focus.md +33 -0
- package/.context/preferences/priorities.md +36 -0
- package/.context/preferences/task-naming.md +28 -0
- package/.context/profile.md +29 -0
- package/.context/projects/_index.md +41 -0
- package/.context/projects/papers/nudge-exp.md +22 -0
- package/.context/projects/papers/uncertainty.md +31 -0
- package/.context/resources/claude-scientific-writer-review.md +48 -0
- package/.context/resources/cunningham-multi-analyst-agents.md +104 -0
- package/.context/resources/cunningham-multilang-code-audit.md +62 -0
- package/.context/resources/google-ai-co-scientist-review.md +72 -0
- package/.context/resources/karpathy-llm-council-review.md +58 -0
- package/.context/resources/multi-coder-reliability-protocol.md +175 -0
- package/.context/resources/pedro-santanna-takeaways.md +96 -0
- package/.context/resources/venue-rankings/abs_ajg_2024.csv +1823 -0
- package/.context/resources/venue-rankings/abs_ajg_2024_econ.csv +356 -0
- package/.context/resources/venue-rankings/cabs_4_4star_theory.csv +40 -0
- package/.context/resources/venue-rankings/core_2026.csv +801 -0
- package/.context/resources/venue-rankings.md +147 -0
- package/.context/workflows/README.md +69 -0
- package/.context/workflows/daily-review.md +91 -0
- package/.context/workflows/meeting-actions.md +108 -0
- package/.context/workflows/replication-protocol.md +155 -0
- package/.context/workflows/weekly-review.md +113 -0
- package/.mcp-server-biblio/formatters.py +158 -0
- package/.mcp-server-biblio/pyproject.toml +11 -0
- package/.mcp-server-biblio/server.py +678 -0
- package/.mcp-server-biblio/sources/__init__.py +14 -0
- package/.mcp-server-biblio/sources/base.py +73 -0
- package/.mcp-server-biblio/sources/formatters.py +83 -0
- package/.mcp-server-biblio/sources/models.py +22 -0
- package/.mcp-server-biblio/sources/multi_source.py +243 -0
- package/.mcp-server-biblio/sources/openalex_source.py +183 -0
- package/.mcp-server-biblio/sources/scopus_source.py +309 -0
- package/.mcp-server-biblio/sources/wos_source.py +508 -0
- package/.mcp-server-biblio/uv.lock +896 -0
- package/.scripts/README.md +161 -0
- package/.scripts/ai_pattern_density.py +446 -0
- package/.scripts/conf +445 -0
- package/.scripts/config.py +122 -0
- package/.scripts/count_inventory.py +275 -0
- package/.scripts/daily_digest.py +288 -0
- package/.scripts/done +177 -0
- package/.scripts/extract_meeting_actions.py +223 -0
- package/.scripts/focus +176 -0
- package/.scripts/generate-codex-agents-md.py +217 -0
- package/.scripts/inbox +194 -0
- package/.scripts/notion_helpers.py +325 -0
- package/.scripts/openalex/query_helpers.py +306 -0
- package/.scripts/papers +227 -0
- package/.scripts/query +223 -0
- package/.scripts/session-history.py +201 -0
- package/.scripts/skill-health.py +516 -0
- package/.scripts/skill-log-miner.py +273 -0
- package/.scripts/sync-to-codex.sh +252 -0
- package/.scripts/task +213 -0
- package/.scripts/tasks +190 -0
- package/.scripts/week +206 -0
- package/CLAUDE.md +197 -0
- package/LICENSE +21 -0
- package/MEMORY.md +38 -0
- package/README.md +269 -0
- package/docs/agents.md +44 -0
- package/docs/bibliography-setup.md +55 -0
- package/docs/council-mode.md +36 -0
- package/docs/getting-started.md +245 -0
- package/docs/hooks.md +38 -0
- package/docs/mcp-servers.md +82 -0
- package/docs/notion-setup.md +109 -0
- package/docs/rules.md +33 -0
- package/docs/scripts.md +303 -0
- package/docs/setup-overview/setup-overview.pdf +0 -0
- package/docs/skills.md +70 -0
- package/docs/system.md +159 -0
- package/hooks/block-destructive-git.sh +66 -0
- package/hooks/context-monitor.py +114 -0
- package/hooks/postcompact-restore.py +157 -0
- package/hooks/precompact-autosave.py +181 -0
- package/hooks/promise-checker.sh +124 -0
- package/hooks/protect-source-files.sh +81 -0
- package/hooks/resume-context-loader.sh +53 -0
- package/hooks/startup-context-loader.sh +102 -0
- package/package.json +51 -0
- package/packages/cli-council/.github/workflows/claude-code-review.yml +44 -0
- package/packages/cli-council/.github/workflows/claude.yml +50 -0
- package/packages/cli-council/README.md +100 -0
- package/packages/cli-council/pyproject.toml +43 -0
- package/packages/cli-council/src/cli_council/__init__.py +19 -0
- package/packages/cli-council/src/cli_council/__main__.py +185 -0
- package/packages/cli-council/src/cli_council/backends/__init__.py +8 -0
- package/packages/cli-council/src/cli_council/backends/base.py +81 -0
- package/packages/cli-council/src/cli_council/backends/claude.py +25 -0
- package/packages/cli-council/src/cli_council/backends/codex.py +27 -0
- package/packages/cli-council/src/cli_council/backends/gemini.py +26 -0
- package/packages/cli-council/src/cli_council/checkpoint.py +212 -0
- package/packages/cli-council/src/cli_council/config.py +51 -0
- package/packages/cli-council/src/cli_council/council.py +391 -0
- package/packages/cli-council/src/cli_council/models.py +46 -0
- package/packages/llm-council/.github/workflows/claude-code-review.yml +44 -0
- package/packages/llm-council/.github/workflows/claude.yml +50 -0
- package/packages/llm-council/README.md +453 -0
- package/packages/llm-council/pyproject.toml +42 -0
- package/packages/llm-council/src/llm_council/__init__.py +23 -0
- package/packages/llm-council/src/llm_council/__main__.py +259 -0
- package/packages/llm-council/src/llm_council/checkpoint.py +193 -0
- package/packages/llm-council/src/llm_council/client.py +253 -0
- package/packages/llm-council/src/llm_council/config.py +232 -0
- package/packages/llm-council/src/llm_council/council.py +482 -0
- package/packages/llm-council/src/llm_council/models.py +46 -0
- package/packages/mcp-bibliography/MEMORY.md +31 -0
- package/packages/mcp-bibliography/_app.py +226 -0
- package/packages/mcp-bibliography/formatters.py +158 -0
- package/packages/mcp-bibliography/log/2026-03-13-2100.md +35 -0
- package/packages/mcp-bibliography/pyproject.toml +15 -0
- package/packages/mcp-bibliography/run.sh +20 -0
- package/packages/mcp-bibliography/scholarly_formatters.py +83 -0
- package/packages/mcp-bibliography/server.py +1857 -0
- package/packages/mcp-bibliography/tools/__init__.py +28 -0
- package/packages/mcp-bibliography/tools/_registry.py +19 -0
- package/packages/mcp-bibliography/tools/altmetric.py +107 -0
- package/packages/mcp-bibliography/tools/core.py +92 -0
- package/packages/mcp-bibliography/tools/dblp.py +52 -0
- package/packages/mcp-bibliography/tools/openalex.py +296 -0
- package/packages/mcp-bibliography/tools/opencitations.py +102 -0
- package/packages/mcp-bibliography/tools/openreview.py +179 -0
- package/packages/mcp-bibliography/tools/orcid.py +131 -0
- package/packages/mcp-bibliography/tools/scholarly.py +575 -0
- package/packages/mcp-bibliography/tools/unpaywall.py +63 -0
- package/packages/mcp-bibliography/tools/zenodo.py +123 -0
- package/packages/mcp-bibliography/uv.lock +711 -0
- package/scripts/setup.sh +143 -0
- package/skills/beamer-deck/SKILL.md +199 -0
- package/skills/beamer-deck/references/quality-rubric.md +54 -0
- package/skills/beamer-deck/references/review-prompts.md +106 -0
- package/skills/bib-validate/SKILL.md +261 -0
- package/skills/bib-validate/references/council-mode.md +34 -0
- package/skills/bib-validate/references/deep-verify.md +79 -0
- package/skills/bib-validate/references/fix-mode.md +36 -0
- package/skills/bib-validate/references/openalex-verification.md +45 -0
- package/skills/bib-validate/references/preprint-check.md +31 -0
- package/skills/bib-validate/references/ref-manager-crossref.md +41 -0
- package/skills/bib-validate/references/report-template.md +82 -0
- package/skills/code-archaeology/SKILL.md +141 -0
- package/skills/code-review/SKILL.md +265 -0
- package/skills/code-review/references/quality-rubric.md +67 -0
- package/skills/consolidate-memory/SKILL.md +208 -0
- package/skills/context-status/SKILL.md +126 -0
- package/skills/creation-guard/SKILL.md +230 -0
- package/skills/devils-advocate/SKILL.md +130 -0
- package/skills/devils-advocate/references/competing-hypotheses.md +83 -0
- package/skills/init-project/SKILL.md +115 -0
- package/skills/init-project-course/references/memory-and-settings.md +92 -0
- package/skills/init-project-course/references/organise-templates.md +94 -0
- package/skills/init-project-course/skill.md +147 -0
- package/skills/init-project-light/skill.md +139 -0
- package/skills/init-project-research/SKILL.md +368 -0
- package/skills/init-project-research/references/atlas-pipeline-sync.md +70 -0
- package/skills/init-project-research/references/atlas-schema.md +81 -0
- package/skills/init-project-research/references/confirmation-report.md +39 -0
- package/skills/init-project-research/references/domain-profile-template.md +104 -0
- package/skills/init-project-research/references/interview-round3.md +34 -0
- package/skills/init-project-research/references/literature-discovery.md +43 -0
- package/skills/init-project-research/references/scaffold-details.md +197 -0
- package/skills/init-project-research/templates/field-calibration.md +60 -0
- package/skills/init-project-research/templates/pipeline-manifest.md +63 -0
- package/skills/init-project-research/templates/run-all.sh +116 -0
- package/skills/init-project-research/templates/seed-files.md +337 -0
- package/skills/insights-deck/SKILL.md +151 -0
- package/skills/interview-me/SKILL.md +157 -0
- package/skills/latex/SKILL.md +141 -0
- package/skills/latex/references/latex-configs.md +183 -0
- package/skills/latex-autofix/SKILL.md +230 -0
- package/skills/latex-autofix/references/known-errors.md +183 -0
- package/skills/latex-autofix/references/quality-rubric.md +50 -0
- package/skills/latex-health-check/SKILL.md +161 -0
- package/skills/learn/SKILL.md +220 -0
- package/skills/learn/scripts/validate_skill.py +265 -0
- package/skills/lessons-learned/SKILL.md +201 -0
- package/skills/literature/SKILL.md +335 -0
- package/skills/literature/references/agent-templates.md +393 -0
- package/skills/literature/references/bibliometric-apis.md +44 -0
- package/skills/literature/references/cli-council-search.md +79 -0
- package/skills/literature/references/openalex-api-guide.md +371 -0
- package/skills/literature/references/openalex-common-queries.md +381 -0
- package/skills/literature/references/openalex-workflows.md +248 -0
- package/skills/literature/references/reference-manager-sync.md +36 -0
- package/skills/literature/references/scopus-api-guide.md +208 -0
- package/skills/literature/references/wos-api-guide.md +308 -0
- package/skills/multi-perspective/SKILL.md +311 -0
- package/skills/multi-perspective/references/computational-many-analysts.md +77 -0
- package/skills/pipeline-manifest/SKILL.md +226 -0
- package/skills/pre-submission-report/SKILL.md +153 -0
- package/skills/process-reviews/SKILL.md +244 -0
- package/skills/process-reviews/references/rr-routing.md +101 -0
- package/skills/project-deck/SKILL.md +87 -0
- package/skills/project-safety/SKILL.md +135 -0
- package/skills/proofread/SKILL.md +254 -0
- package/skills/proofread/references/quality-rubric.md +104 -0
- package/skills/python-env/SKILL.md +57 -0
- package/skills/quarto-deck/SKILL.md +226 -0
- package/skills/quarto-deck/references/markdown-format.md +143 -0
- package/skills/quarto-deck/references/quality-rubric.md +54 -0
- package/skills/save-context/SKILL.md +174 -0
- package/skills/session-log/SKILL.md +98 -0
- package/skills/shared/concept-validation-gate.md +161 -0
- package/skills/shared/council-protocol.md +265 -0
- package/skills/shared/distribution-diagnostics.md +164 -0
- package/skills/shared/engagement-stratified-sampling.md +218 -0
- package/skills/shared/escalation-protocol.md +74 -0
- package/skills/shared/external-audit-protocol.md +205 -0
- package/skills/shared/intercoder-reliability.md +256 -0
- package/skills/shared/mcp-degradation.md +81 -0
- package/skills/shared/method-probing-questions.md +163 -0
- package/skills/shared/multi-language-conventions.md +143 -0
- package/skills/shared/paid-api-safety.md +174 -0
- package/skills/shared/palettes.md +90 -0
- package/skills/shared/progressive-disclosure.md +92 -0
- package/skills/shared/project-documentation-content.md +443 -0
- package/skills/shared/project-documentation-format.md +281 -0
- package/skills/shared/project-documentation.md +100 -0
- package/skills/shared/publication-output.md +138 -0
- package/skills/shared/quality-scoring.md +70 -0
- package/skills/shared/reference-resolution.md +77 -0
- package/skills/shared/research-quality-rubric.md +165 -0
- package/skills/shared/rhetoric-principles.md +54 -0
- package/skills/shared/skill-design-patterns.md +272 -0
- package/skills/shared/skill-index.md +240 -0
- package/skills/shared/system-documentation.md +334 -0
- package/skills/shared/tikz-rules.md +402 -0
- package/skills/shared/validation-tiers.md +121 -0
- package/skills/shared/venue-guides/README.md +46 -0
- package/skills/shared/venue-guides/cell_press_style.md +483 -0
- package/skills/shared/venue-guides/conferences_formatting.md +564 -0
- package/skills/shared/venue-guides/cs_conference_style.md +463 -0
- package/skills/shared/venue-guides/examples/cell_summary_example.md +247 -0
- package/skills/shared/venue-guides/examples/medical_structured_abstract.md +313 -0
- package/skills/shared/venue-guides/examples/nature_abstract_examples.md +213 -0
- package/skills/shared/venue-guides/examples/neurips_introduction_example.md +245 -0
- package/skills/shared/venue-guides/journals_formatting.md +486 -0
- package/skills/shared/venue-guides/medical_journal_styles.md +535 -0
- package/skills/shared/venue-guides/ml_conference_style.md +556 -0
- package/skills/shared/venue-guides/nature_science_style.md +405 -0
- package/skills/shared/venue-guides/reviewer_expectations.md +417 -0
- package/skills/shared/venue-guides/venue_writing_styles.md +321 -0
- package/skills/split-pdf/SKILL.md +172 -0
- package/skills/split-pdf/methodology.md +48 -0
- package/skills/sync-notion/SKILL.md +93 -0
- package/skills/system-audit/SKILL.md +157 -0
- package/skills/system-audit/references/sub-agent-prompts.md +294 -0
- package/skills/task-management/SKILL.md +131 -0
- package/skills/update-focus/SKILL.md +204 -0
- package/skills/update-project-doc/SKILL.md +194 -0
- package/skills/validate-bib/SKILL.md +242 -0
- package/skills/validate-bib/references/council-mode.md +34 -0
- package/skills/validate-bib/references/deep-verify.md +71 -0
- package/skills/validate-bib/references/openalex-verification.md +45 -0
- package/skills/validate-bib/references/preprint-check.md +31 -0
- package/skills/validate-bib/references/report-template.md +62 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""CLI entry point for running council deliberations.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
# Run a council
|
|
5
|
+
llm-council \\
|
|
6
|
+
--system-prompt "You are a paper reviewer..." \\
|
|
7
|
+
--user-message "Review this paper: ..." \\
|
|
8
|
+
--models "anthropic/claude-sonnet-4.5,openai/gpt-4.1,google/gemini-2.5-pro" \\
|
|
9
|
+
--chairman "anthropic/claude-sonnet-4.5" \\
|
|
10
|
+
--output result.json
|
|
11
|
+
|
|
12
|
+
# Or read prompts from files:
|
|
13
|
+
llm-council \\
|
|
14
|
+
--system-prompt-file system.txt \\
|
|
15
|
+
--user-message-file user.txt
|
|
16
|
+
|
|
17
|
+
# Manage models
|
|
18
|
+
llm-council models # show available + defaults
|
|
19
|
+
llm-council models --pricing # include OpenRouter pricing
|
|
20
|
+
llm-council models --set-defaults "m1,m2" # set default council models
|
|
21
|
+
llm-council models --set-chairman "m1" # set default chairman
|
|
22
|
+
llm-council models --reset # revert to built-in defaults
|
|
23
|
+
|
|
24
|
+
Environment:
|
|
25
|
+
OPENROUTER_API_KEY Required for council runs and pricing lookups.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import asyncio
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
from llm_council.client import LLMClient
|
|
37
|
+
from llm_council.config import (
|
|
38
|
+
AVAILABLE_MODELS,
|
|
39
|
+
USER_CONFIG_PATH,
|
|
40
|
+
_BUILTIN_DEFAULT_CHAIRMAN,
|
|
41
|
+
_BUILTIN_DEFAULT_MODELS,
|
|
42
|
+
get_chairman_default,
|
|
43
|
+
get_council_defaults,
|
|
44
|
+
reset_council_defaults,
|
|
45
|
+
set_council_defaults,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- Models subcommand ---
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _models_command(args: argparse.Namespace) -> None:
|
|
53
|
+
if args.reset:
|
|
54
|
+
reset_council_defaults()
|
|
55
|
+
print("Reset to built-in defaults.")
|
|
56
|
+
print(f" Council: {', '.join(_BUILTIN_DEFAULT_MODELS)}")
|
|
57
|
+
print(f" Chairman: {_BUILTIN_DEFAULT_CHAIRMAN}")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if args.set_defaults:
|
|
61
|
+
models = [m.strip() for m in args.set_defaults.split(",")]
|
|
62
|
+
known_ids = {m["id"] for m in AVAILABLE_MODELS}
|
|
63
|
+
unknown = [m for m in models if m not in known_ids]
|
|
64
|
+
if unknown:
|
|
65
|
+
print(f"Warning: unknown model(s): {', '.join(unknown)}", file=sys.stderr)
|
|
66
|
+
print("They will be saved but may not work on OpenRouter.", file=sys.stderr)
|
|
67
|
+
set_council_defaults(models=models)
|
|
68
|
+
print(f"Default council models set: {', '.join(models)}")
|
|
69
|
+
|
|
70
|
+
if args.set_chairman:
|
|
71
|
+
set_council_defaults(chairman=args.set_chairman)
|
|
72
|
+
print(f"Default chairman set: {args.set_chairman}")
|
|
73
|
+
|
|
74
|
+
if args.set_defaults or args.set_chairman:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# List models
|
|
78
|
+
council_defaults = get_council_defaults()
|
|
79
|
+
chairman = get_chairman_default()
|
|
80
|
+
is_custom = USER_CONFIG_PATH.exists()
|
|
81
|
+
|
|
82
|
+
if args.pricing:
|
|
83
|
+
from llm_council.config import fetch_model_pricing
|
|
84
|
+
|
|
85
|
+
models = await fetch_model_pricing()
|
|
86
|
+
else:
|
|
87
|
+
models = [m.copy() for m in AVAILABLE_MODELS]
|
|
88
|
+
|
|
89
|
+
# Group by provider
|
|
90
|
+
providers: dict[str, list[dict]] = {}
|
|
91
|
+
for m in models:
|
|
92
|
+
provider = m["id"].split("/")[0]
|
|
93
|
+
providers.setdefault(provider, []).append(m)
|
|
94
|
+
|
|
95
|
+
source = "user config" if is_custom else "built-in"
|
|
96
|
+
print(f"Council defaults ({source}):")
|
|
97
|
+
print(f" Models: {', '.join(council_defaults)}")
|
|
98
|
+
print(f" Chairman: {chairman}")
|
|
99
|
+
if is_custom:
|
|
100
|
+
print(f" Config: {USER_CONFIG_PATH}")
|
|
101
|
+
print()
|
|
102
|
+
|
|
103
|
+
print(f"Available models ({len(models)}):")
|
|
104
|
+
for provider in ["anthropic", "openai", "google"]:
|
|
105
|
+
if provider not in providers:
|
|
106
|
+
continue
|
|
107
|
+
print(f"\n {provider.title()}:")
|
|
108
|
+
for m in providers[provider]:
|
|
109
|
+
marker = ""
|
|
110
|
+
if m["id"] in council_defaults and m["id"] == chairman:
|
|
111
|
+
marker = " [default, chairman]"
|
|
112
|
+
elif m["id"] in council_defaults:
|
|
113
|
+
marker = " [default]"
|
|
114
|
+
elif m["id"] == chairman:
|
|
115
|
+
marker = " [chairman]"
|
|
116
|
+
|
|
117
|
+
price = ""
|
|
118
|
+
if args.pricing and "input_price" in m:
|
|
119
|
+
price = f" ({m['input_price']}/{m['output_price']} per 1M tok)"
|
|
120
|
+
|
|
121
|
+
print(f" {m['id']:45s} {m.get('tier', ''):25s}{marker}{price}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- Run subcommand (existing behavior) ---
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _run_command(args: argparse.Namespace) -> None:
|
|
128
|
+
api_key = os.environ.get("OPENROUTER_API_KEY", "")
|
|
129
|
+
if not api_key:
|
|
130
|
+
print("Error: OPENROUTER_API_KEY environment variable not set.", file=sys.stderr)
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
|
|
133
|
+
if args.system_prompt_file:
|
|
134
|
+
system_prompt = open(args.system_prompt_file).read()
|
|
135
|
+
else:
|
|
136
|
+
system_prompt = args.system_prompt or "You are a helpful expert assistant."
|
|
137
|
+
|
|
138
|
+
if args.user_message_file:
|
|
139
|
+
user_msg = open(args.user_message_file).read()
|
|
140
|
+
else:
|
|
141
|
+
user_msg = args.user_message or ""
|
|
142
|
+
|
|
143
|
+
if not user_msg:
|
|
144
|
+
print("Error: --user-message or --user-message-file required.", file=sys.stderr)
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
|
|
147
|
+
models = [m.strip() for m in args.models.split(",")]
|
|
148
|
+
chairman = args.chairman
|
|
149
|
+
|
|
150
|
+
from llm_council.council import CouncilService
|
|
151
|
+
|
|
152
|
+
llm = LLMClient(api_key=api_key, max_tokens=args.max_tokens)
|
|
153
|
+
council = CouncilService(llm)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
result = await council.run_council(
|
|
157
|
+
system_prompt=system_prompt,
|
|
158
|
+
user_msg=user_msg,
|
|
159
|
+
council_models=models,
|
|
160
|
+
chairman_model=chairman,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
output = result.model_dump()
|
|
164
|
+
|
|
165
|
+
if args.output:
|
|
166
|
+
with open(args.output, "w") as f:
|
|
167
|
+
json.dump(output, f, indent=2)
|
|
168
|
+
print(f"Result written to {args.output}", file=sys.stderr)
|
|
169
|
+
else:
|
|
170
|
+
print(json.dumps(output, indent=2))
|
|
171
|
+
|
|
172
|
+
finally:
|
|
173
|
+
await llm.close()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> None:
|
|
177
|
+
parser = argparse.ArgumentParser(
|
|
178
|
+
description="Multi-model LLM council via OpenRouter.",
|
|
179
|
+
)
|
|
180
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
181
|
+
|
|
182
|
+
# --- models subcommand ---
|
|
183
|
+
models_parser = subparsers.add_parser(
|
|
184
|
+
"models", help="List available models and manage defaults.",
|
|
185
|
+
)
|
|
186
|
+
models_parser.add_argument(
|
|
187
|
+
"--pricing", action="store_true",
|
|
188
|
+
help="Fetch and display OpenRouter pricing.",
|
|
189
|
+
)
|
|
190
|
+
models_parser.add_argument(
|
|
191
|
+
"--set-defaults", type=str, metavar="MODELS",
|
|
192
|
+
help="Set default council models (comma-separated model IDs).",
|
|
193
|
+
)
|
|
194
|
+
models_parser.add_argument(
|
|
195
|
+
"--set-chairman", type=str, metavar="MODEL",
|
|
196
|
+
help="Set default chairman model.",
|
|
197
|
+
)
|
|
198
|
+
models_parser.add_argument(
|
|
199
|
+
"--reset", action="store_true",
|
|
200
|
+
help="Remove user config and revert to built-in defaults.",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# --- run subcommand (also the default) ---
|
|
204
|
+
run_parser = subparsers.add_parser(
|
|
205
|
+
"run", help="Run a council deliberation.",
|
|
206
|
+
)
|
|
207
|
+
_add_run_args(run_parser)
|
|
208
|
+
|
|
209
|
+
# Also add run args to the main parser for backwards compatibility
|
|
210
|
+
_add_run_args(parser)
|
|
211
|
+
|
|
212
|
+
args = parser.parse_args()
|
|
213
|
+
|
|
214
|
+
if args.command == "models":
|
|
215
|
+
asyncio.run(_models_command(args))
|
|
216
|
+
else:
|
|
217
|
+
asyncio.run(_run_command(args))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _add_run_args(parser: argparse.ArgumentParser) -> None:
|
|
221
|
+
"""Add the run-command arguments to a parser."""
|
|
222
|
+
parser.add_argument(
|
|
223
|
+
"--system-prompt", type=str, default=None,
|
|
224
|
+
help="System prompt for Stage 1 assessments.",
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
"--system-prompt-file", type=str, default=None,
|
|
228
|
+
help="Read system prompt from a file.",
|
|
229
|
+
)
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
"--user-message", type=str, default=None,
|
|
232
|
+
help="User message for Stage 1 assessments.",
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument(
|
|
235
|
+
"--user-message-file", type=str, default=None,
|
|
236
|
+
help="Read user message from a file.",
|
|
237
|
+
)
|
|
238
|
+
parser.add_argument(
|
|
239
|
+
"--models", type=str,
|
|
240
|
+
default=",".join(get_council_defaults()),
|
|
241
|
+
help="Comma-separated list of OpenRouter model IDs.",
|
|
242
|
+
)
|
|
243
|
+
parser.add_argument(
|
|
244
|
+
"--chairman", type=str,
|
|
245
|
+
default=get_chairman_default(),
|
|
246
|
+
help="Model ID for the chairman synthesis.",
|
|
247
|
+
)
|
|
248
|
+
parser.add_argument(
|
|
249
|
+
"--max-tokens", type=int, default=4096,
|
|
250
|
+
help="Max tokens per LLM response.",
|
|
251
|
+
)
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
"--output", "-o", type=str, default=None,
|
|
254
|
+
help="Write JSON result to file instead of stdout.",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
main()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Checkpoint and file-based coordination for council runs.
|
|
2
|
+
|
|
3
|
+
Inspired by:
|
|
4
|
+
- Owlex (agentic-mcp-tools/owlex): session resumption across deliberation rounds
|
|
5
|
+
- agents-council (MrLesk/agents-council): atomic file writes, cursor-based state
|
|
6
|
+
|
|
7
|
+
Provides:
|
|
8
|
+
- Atomic JSON writes (tmp + fsync + rename) for crash safety
|
|
9
|
+
- Stage checkpointing: save after each stage, resume from last completed stage
|
|
10
|
+
- Pending participant tracking
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
DEFAULT_CHECKPOINT_DIR = ".council"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
29
|
+
"""Write JSON atomically: tmp file -> fsync -> rename.
|
|
30
|
+
|
|
31
|
+
If the process crashes mid-write, the original file is preserved.
|
|
32
|
+
"""
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
35
|
+
dir=str(path.parent), suffix=".tmp", prefix=".ckpt-",
|
|
36
|
+
)
|
|
37
|
+
try:
|
|
38
|
+
with os.fdopen(fd, "w") as f:
|
|
39
|
+
json.dump(data, f, indent=2, default=str)
|
|
40
|
+
f.flush()
|
|
41
|
+
os.fsync(f.fileno())
|
|
42
|
+
os.rename(tmp_path, str(path))
|
|
43
|
+
except BaseException:
|
|
44
|
+
try:
|
|
45
|
+
os.unlink(tmp_path)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_json(path: Path) -> dict | None:
|
|
52
|
+
"""Read a JSON checkpoint file, returning None if missing or corrupt."""
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(path.read_text())
|
|
57
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
58
|
+
logger.warning("Corrupt checkpoint %s: %s", path, exc)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _make_run_id() -> str:
|
|
63
|
+
"""Generate a timestamped run ID."""
|
|
64
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class CouncilCheckpointer:
|
|
69
|
+
"""Manages checkpoint state for a council run."""
|
|
70
|
+
|
|
71
|
+
checkpoint_dir: Path
|
|
72
|
+
run_id: str = field(default_factory=_make_run_id)
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
self.checkpoint_dir = Path(self.checkpoint_dir)
|
|
76
|
+
|
|
77
|
+
# ---- Save ----
|
|
78
|
+
|
|
79
|
+
def save_stage1(self, assessments: list[dict], models: list[str]) -> Path:
|
|
80
|
+
"""Save Stage 1 assessments to checkpoint."""
|
|
81
|
+
path = self._stage_path(1)
|
|
82
|
+
data = {
|
|
83
|
+
"meta": {
|
|
84
|
+
"run_id": self.run_id,
|
|
85
|
+
"stage": 1,
|
|
86
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
87
|
+
"models": models,
|
|
88
|
+
},
|
|
89
|
+
"assessments": assessments,
|
|
90
|
+
}
|
|
91
|
+
_atomic_write_json(path, data)
|
|
92
|
+
logger.info("Checkpoint saved: Stage 1 → %s", path)
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
def save_stage2(
|
|
96
|
+
self,
|
|
97
|
+
peer_reviews: list[dict],
|
|
98
|
+
models: list[str],
|
|
99
|
+
aggregate_rankings: list[dict] | None = None,
|
|
100
|
+
) -> Path:
|
|
101
|
+
"""Save Stage 2 peer reviews to checkpoint."""
|
|
102
|
+
path = self._stage_path(2)
|
|
103
|
+
data = {
|
|
104
|
+
"meta": {
|
|
105
|
+
"run_id": self.run_id,
|
|
106
|
+
"stage": 2,
|
|
107
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
108
|
+
"models": models,
|
|
109
|
+
},
|
|
110
|
+
"peer_reviews": peer_reviews,
|
|
111
|
+
"aggregate_rankings": aggregate_rankings or [],
|
|
112
|
+
}
|
|
113
|
+
_atomic_write_json(path, data)
|
|
114
|
+
logger.info("Checkpoint saved: Stage 2 → %s", path)
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
def save_stage3(self, synthesis: dict | str, chairman: str) -> Path:
|
|
118
|
+
"""Save Stage 3 synthesis to checkpoint."""
|
|
119
|
+
path = self._stage_path(3)
|
|
120
|
+
data = {
|
|
121
|
+
"meta": {
|
|
122
|
+
"run_id": self.run_id,
|
|
123
|
+
"stage": 3,
|
|
124
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
125
|
+
"chairman": chairman,
|
|
126
|
+
},
|
|
127
|
+
"synthesis": synthesis,
|
|
128
|
+
}
|
|
129
|
+
_atomic_write_json(path, data)
|
|
130
|
+
logger.info("Checkpoint saved: Stage 3 → %s", path)
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
# ---- Load / Resume ----
|
|
134
|
+
|
|
135
|
+
def last_completed_stage(self) -> int:
|
|
136
|
+
"""Return the highest completed stage (0 if none)."""
|
|
137
|
+
for stage in (3, 2, 1):
|
|
138
|
+
if self._stage_path(stage).exists():
|
|
139
|
+
return stage
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
def load_stage1(self) -> list[dict] | None:
|
|
143
|
+
"""Load Stage 1 assessments from checkpoint."""
|
|
144
|
+
data = _read_json(self._stage_path(1))
|
|
145
|
+
if data is None:
|
|
146
|
+
return None
|
|
147
|
+
return data.get("assessments")
|
|
148
|
+
|
|
149
|
+
def load_stage2(self) -> tuple[list[dict], list[dict]] | None:
|
|
150
|
+
"""Load Stage 2 peer reviews + aggregate rankings from checkpoint."""
|
|
151
|
+
data = _read_json(self._stage_path(2))
|
|
152
|
+
if data is None:
|
|
153
|
+
return None
|
|
154
|
+
return data.get("peer_reviews", []), data.get("aggregate_rankings", [])
|
|
155
|
+
|
|
156
|
+
def load_stage3(self) -> dict | str | None:
|
|
157
|
+
"""Load Stage 3 synthesis from checkpoint."""
|
|
158
|
+
data = _read_json(self._stage_path(3))
|
|
159
|
+
if data is None:
|
|
160
|
+
return None
|
|
161
|
+
return data.get("synthesis")
|
|
162
|
+
|
|
163
|
+
# ---- Pending participants ----
|
|
164
|
+
|
|
165
|
+
def pending_participants(
|
|
166
|
+
self, all_models: list[str], responded: list[str],
|
|
167
|
+
) -> list[str]:
|
|
168
|
+
"""Return models that haven't responded yet."""
|
|
169
|
+
return [m for m in all_models if m not in set(responded)]
|
|
170
|
+
|
|
171
|
+
# ---- Discovery ----
|
|
172
|
+
|
|
173
|
+
def find_latest_run(self) -> str | None:
|
|
174
|
+
"""Find the most recent run_id in the checkpoint directory."""
|
|
175
|
+
if not self.checkpoint_dir.exists():
|
|
176
|
+
return None
|
|
177
|
+
stage1_files = sorted(self.checkpoint_dir.glob("*-stage1.json"), reverse=True)
|
|
178
|
+
if not stage1_files:
|
|
179
|
+
return None
|
|
180
|
+
return stage1_files[0].stem.replace("-stage1", "")
|
|
181
|
+
|
|
182
|
+
def clean(self) -> None:
|
|
183
|
+
"""Remove all checkpoint files for this run."""
|
|
184
|
+
for stage in (1, 2, 3):
|
|
185
|
+
path = self._stage_path(stage)
|
|
186
|
+
if path.exists():
|
|
187
|
+
path.unlink()
|
|
188
|
+
logger.info("Removed checkpoint: %s", path)
|
|
189
|
+
|
|
190
|
+
# ---- Internal ----
|
|
191
|
+
|
|
192
|
+
def _stage_path(self, stage: int) -> Path:
|
|
193
|
+
return self.checkpoint_dir / f"{self.run_id}-stage{stage}.json"
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Generic OpenRouter LLM client.
|
|
2
|
+
|
|
3
|
+
OpenRouter wrapper with JSON parsing, retry logic, and error handling.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import openai
|
|
13
|
+
from openai import AsyncOpenAI
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
18
|
+
OPENROUTER_CREDITS_URL = "https://openrouter.ai/credits"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LLMResponseFormatError(RuntimeError):
|
|
22
|
+
"""Raised when an LLM response cannot be parsed into valid JSON."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LLMServiceError(Exception):
|
|
26
|
+
"""User-friendly error from the LLM service."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
*,
|
|
32
|
+
help_url: str | None = None,
|
|
33
|
+
detail: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.help_url = help_url
|
|
37
|
+
self.detail = detail
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _handle_openai_error(exc: Exception) -> LLMServiceError:
|
|
41
|
+
"""Convert openai SDK exceptions into user-friendly LLMServiceError."""
|
|
42
|
+
if isinstance(exc, openai.AuthenticationError):
|
|
43
|
+
return LLMServiceError(
|
|
44
|
+
"Invalid OpenRouter API key. Please check your OPENROUTER_API_KEY.",
|
|
45
|
+
help_url=OPENROUTER_CREDITS_URL,
|
|
46
|
+
detail=str(exc),
|
|
47
|
+
)
|
|
48
|
+
if isinstance(exc, openai.RateLimitError):
|
|
49
|
+
return LLMServiceError(
|
|
50
|
+
"OpenRouter rate limit reached. Please wait a moment and try again.",
|
|
51
|
+
help_url=OPENROUTER_CREDITS_URL,
|
|
52
|
+
detail=str(exc),
|
|
53
|
+
)
|
|
54
|
+
if isinstance(exc, openai.APIStatusError):
|
|
55
|
+
status = getattr(exc, "status_code", None)
|
|
56
|
+
if status == 402:
|
|
57
|
+
return LLMServiceError(
|
|
58
|
+
"Insufficient OpenRouter credits. Please top up your account.",
|
|
59
|
+
help_url=OPENROUTER_CREDITS_URL,
|
|
60
|
+
detail=str(exc),
|
|
61
|
+
)
|
|
62
|
+
if status == 503:
|
|
63
|
+
return LLMServiceError(
|
|
64
|
+
"The selected LLM model is temporarily unavailable. Try a different model.",
|
|
65
|
+
detail=str(exc),
|
|
66
|
+
)
|
|
67
|
+
return LLMServiceError(
|
|
68
|
+
f"OpenRouter API error (HTTP {status}). Please try again.",
|
|
69
|
+
help_url=OPENROUTER_CREDITS_URL,
|
|
70
|
+
detail=str(exc),
|
|
71
|
+
)
|
|
72
|
+
if isinstance(exc, openai.APIConnectionError):
|
|
73
|
+
return LLMServiceError(
|
|
74
|
+
"Could not connect to OpenRouter. Please check your internet connection.",
|
|
75
|
+
detail=str(exc),
|
|
76
|
+
)
|
|
77
|
+
return LLMServiceError(
|
|
78
|
+
"LLM request failed unexpectedly. Please try again.",
|
|
79
|
+
detail=str(exc),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LLMClient:
|
|
84
|
+
"""Generic async LLM client via OpenRouter.
|
|
85
|
+
|
|
86
|
+
Provides structured JSON and raw text chat methods with retry logic.
|
|
87
|
+
Consumers extend this with domain-specific workflow methods.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
api_key: str,
|
|
93
|
+
model: str = "anthropic/claude-sonnet-4.5",
|
|
94
|
+
max_tokens: int = 4096,
|
|
95
|
+
json_retry_attempts: int = 2,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.client = AsyncOpenAI(
|
|
98
|
+
api_key=api_key,
|
|
99
|
+
base_url=OPENROUTER_BASE_URL,
|
|
100
|
+
)
|
|
101
|
+
self.model = model
|
|
102
|
+
self.max_tokens = max_tokens
|
|
103
|
+
self.json_retry_attempts = max(1, json_retry_attempts)
|
|
104
|
+
|
|
105
|
+
async def chat_json(
|
|
106
|
+
self,
|
|
107
|
+
system: str,
|
|
108
|
+
user_msg: str,
|
|
109
|
+
*,
|
|
110
|
+
model: str | None = None,
|
|
111
|
+
max_tokens: int | None = None,
|
|
112
|
+
) -> dict:
|
|
113
|
+
"""Send a message and parse a JSON-object response with retries.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
max_tokens:
|
|
118
|
+
Override the client's default ``max_tokens`` for this call.
|
|
119
|
+
Useful when the expected JSON response is larger than usual
|
|
120
|
+
(e.g. council synthesis of a discovery workflow).
|
|
121
|
+
"""
|
|
122
|
+
effective_model = model or self.model
|
|
123
|
+
effective_max_tokens = max_tokens or self.max_tokens
|
|
124
|
+
prompt = user_msg + "\n\nRespond ONLY with valid JSON."
|
|
125
|
+
raw_text = ""
|
|
126
|
+
parse_error: Exception | None = None
|
|
127
|
+
|
|
128
|
+
for attempt in range(1, self.json_retry_attempts + 1):
|
|
129
|
+
try:
|
|
130
|
+
response = await self.client.chat.completions.create(
|
|
131
|
+
model=effective_model,
|
|
132
|
+
max_tokens=effective_max_tokens,
|
|
133
|
+
messages=[
|
|
134
|
+
{"role": "system", "content": system},
|
|
135
|
+
{"role": "user", "content": prompt},
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
except (openai.APIError, openai.APIConnectionError) as api_exc:
|
|
139
|
+
raise _handle_openai_error(api_exc) from api_exc
|
|
140
|
+
|
|
141
|
+
raw_text = (response.choices[0].message.content or "").strip()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
return self._parse_json_response(raw_text)
|
|
145
|
+
except LLMResponseFormatError as exc:
|
|
146
|
+
parse_error = exc
|
|
147
|
+
logger.warning(
|
|
148
|
+
"LLM JSON parse failed (attempt %d/%d): %s",
|
|
149
|
+
attempt, self.json_retry_attempts, exc,
|
|
150
|
+
)
|
|
151
|
+
if attempt == self.json_retry_attempts:
|
|
152
|
+
break
|
|
153
|
+
prompt = (
|
|
154
|
+
"Your previous response was not valid JSON.\n"
|
|
155
|
+
"Return ONLY a valid JSON object matching the schema in the system prompt.\n"
|
|
156
|
+
"Do not include markdown fences or commentary.\n\n"
|
|
157
|
+
"Previous invalid response:\n"
|
|
158
|
+
f"{raw_text[:8000]}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
snippet = raw_text[:240].replace("\n", " ")
|
|
162
|
+
raise LLMResponseFormatError(
|
|
163
|
+
f"Failed to parse LLM JSON response after {self.json_retry_attempts} attempts. "
|
|
164
|
+
f"Last parse error: {parse_error}. Response snippet: {snippet!r}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def chat_text(
|
|
168
|
+
self,
|
|
169
|
+
system: str,
|
|
170
|
+
user_msg: str,
|
|
171
|
+
*,
|
|
172
|
+
model: str | None = None,
|
|
173
|
+
max_tokens: int | None = None,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Query the LLM and return the raw text response (no JSON parsing).
|
|
176
|
+
|
|
177
|
+
Used for Stage 2 peer review which returns free-form text with a
|
|
178
|
+
FINAL RANKING section.
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
max_tokens:
|
|
183
|
+
Override the client's default ``max_tokens`` for this call.
|
|
184
|
+
"""
|
|
185
|
+
effective_model = model or self.model
|
|
186
|
+
effective_max_tokens = max_tokens or self.max_tokens
|
|
187
|
+
try:
|
|
188
|
+
response = await self.client.chat.completions.create(
|
|
189
|
+
model=effective_model,
|
|
190
|
+
max_tokens=effective_max_tokens,
|
|
191
|
+
messages=[
|
|
192
|
+
{"role": "system", "content": system},
|
|
193
|
+
{"role": "user", "content": user_msg},
|
|
194
|
+
],
|
|
195
|
+
)
|
|
196
|
+
except (openai.APIError, openai.APIConnectionError) as api_exc:
|
|
197
|
+
raise _handle_openai_error(api_exc) from api_exc
|
|
198
|
+
return (response.choices[0].message.content or "").strip()
|
|
199
|
+
|
|
200
|
+
async def close(self) -> None:
|
|
201
|
+
await self.client.close()
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# JSON parsing utilities
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _extract_json_candidates(text: str) -> list[str]:
|
|
209
|
+
stripped = text.strip()
|
|
210
|
+
candidates: list[str] = []
|
|
211
|
+
|
|
212
|
+
if stripped:
|
|
213
|
+
candidates.append(stripped)
|
|
214
|
+
|
|
215
|
+
fenced_blocks = re.findall(
|
|
216
|
+
r"```(?:json)?\s*(.*?)```", stripped, flags=re.IGNORECASE | re.DOTALL,
|
|
217
|
+
)
|
|
218
|
+
for block in fenced_blocks:
|
|
219
|
+
block = block.strip()
|
|
220
|
+
if block:
|
|
221
|
+
candidates.append(block)
|
|
222
|
+
|
|
223
|
+
first_brace = stripped.find("{")
|
|
224
|
+
last_brace = stripped.rfind("}")
|
|
225
|
+
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
|
226
|
+
candidates.append(stripped[first_brace : last_brace + 1].strip())
|
|
227
|
+
|
|
228
|
+
deduped: list[str] = []
|
|
229
|
+
seen: set[str] = set()
|
|
230
|
+
for candidate in candidates:
|
|
231
|
+
if candidate not in seen:
|
|
232
|
+
seen.add(candidate)
|
|
233
|
+
deduped.append(candidate)
|
|
234
|
+
return deduped
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def _parse_json_response(cls, text: str) -> dict:
|
|
238
|
+
errors: list[str] = []
|
|
239
|
+
for candidate in cls._extract_json_candidates(text):
|
|
240
|
+
try:
|
|
241
|
+
parsed = json.loads(candidate)
|
|
242
|
+
except json.JSONDecodeError as exc:
|
|
243
|
+
errors.append(f"{exc.msg} (line {exc.lineno}, col {exc.colno})")
|
|
244
|
+
continue
|
|
245
|
+
if isinstance(parsed, dict):
|
|
246
|
+
return parsed
|
|
247
|
+
errors.append(f"Expected JSON object, got {type(parsed).__name__}")
|
|
248
|
+
|
|
249
|
+
snippet = text[:240].replace("\n", " ")
|
|
250
|
+
raise LLMResponseFormatError(
|
|
251
|
+
f"Unable to parse JSON object from response. Snippet: {snippet!r}. "
|
|
252
|
+
f"Errors: {errors[:2]}"
|
|
253
|
+
)
|