nkshirsa commited on
Commit
9ce4ec4
·
verified ·
1 Parent(s): ccf9184

Add AI Model Council: phd_research_os/council.py

Browse files
Files changed (1) hide show
  1. phd_research_os/council.py +517 -0
phd_research_os/council.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PhD Research OS — AI Model Council
3
+ ====================================
4
+ The final stage of the Research OS: a multi-agent council that produces
5
+ higher-quality claim extraction through structured debate.
6
+
7
+ Architecture:
8
+ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
9
+ │ Query Planner│ ──▶ │ Extractor │ ──▶ │ Critic │ ──▶ │ Chairman │
10
+ │ │ │ │ │ │ │ │
11
+ │ Decomposes │ │ Extracts │ │ Reviews & │ │ Synthesizes │
12
+ │ complex │ │ atomic │ │ challenges │ │ final claims │
13
+ │ questions │ │ claims │ │ the claims │ │ with penalty │
14
+ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
15
+
16
+ Each council member is a distinct LLM call with a specialized system prompt.
17
+ The pipeline is: decompose → extract → critique → synthesize.
18
+
19
+ This replaces the single-agent extraction with a multi-perspective council
20
+ that catches hallucinations, corrects epistemic tags, and applies the
21
+ 0.7 completeness penalty rigorously.
22
+
23
+ All council output is Provenance Level 5 (LLM Hypothesis) per Research OS spec.
24
+ Human review is still required for promotion to higher provenance levels.
25
+ """
26
+
27
+ import json
28
+ import os
29
+ import time
30
+ from typing import Optional
31
+ from dataclasses import dataclass, field, asdict
32
+
33
+ from .db import get_db, now_iso, gen_id, to_fixed, from_fixed, log_api_usage
34
+ from .taxonomy import TaxonomyManager, ALLOWED_STUDY_TYPES
35
+
36
+
37
+ # ============================================================
38
+ # Council Member System Prompts
39
+ # ============================================================
40
+
41
+ COUNCIL_PROMPTS = {
42
+ "query_planner": """You are an expert search query planner. Given a complex user question, break it down into 2 to 4 distinct, highly specific semantic search queries to be used in a retrieval system.
43
+
44
+ Output the results ONLY as a JSON array of strings.
45
+
46
+ Example input: "What are the environmental impacts of plastic pollution on marine ecosystems, and how does it compare to agricultural runoff?"
47
+ Example output: ["environmental impact of plastic pollution on marine ecosystems", "agricultural runoff impact on marine ecosystems", "comparison of plastic pollution and agricultural runoff on marine ecosystems"]""",
48
+
49
+ "extractor": """You are a scientific claim extractor. Extract precise, atomic claims from the text.
50
+ Each claim should be a single, verifiable statement.
51
+ For each claim, provide:
52
+ - text: The claim statement
53
+ - epistemic_tag: One of [Fact, Interpretation, Hypothesis, Conflict_Hypothesis]
54
+ - confidence: Your confidence in the claim (0.0-1.0)
55
+ - missing_fields: List of what would make this claim more complete
56
+ - status: Either "Complete" or "Incomplete"
57
+
58
+ Output MUST be a valid JSON array only. No explanations, no markdown.""",
59
+
60
+ "critic": """You are a critical reviewer. Review the extracted claims against the original text.
61
+ Check for:
62
+ 1. Missing important claims
63
+ 2. Incorrect epistemic tags
64
+ 3. Overly confident claims that should be marked incomplete
65
+ 4. Taxonomy correctness
66
+ 5. Missing fields that should be identified
67
+
68
+ Provide your critique as JSON with:
69
+ - feedback: Your overall critique
70
+ - missing_claims: Array of claim texts that were missed
71
+ - tag_corrections: Object mapping claim indices to suggested tag corrections
72
+ - confidence_adjustments: Object mapping claim indices to suggested confidence adjustments (0.0-1.0)
73
+ - missing_field_suggestions: Object mapping claim indices to additional missing fields
74
+
75
+ Output MUST be valid JSON only.""",
76
+
77
+ "chairman": """You are the chairman of the council. Synthesize the extraction and critique into final claims.
78
+ Apply a 0.7 completeness penalty if required (when significant missing fields are identified).
79
+ Format the final output as a JSON array of claims matching the exact schema:
80
+ [
81
+ {
82
+ "text": "claim statement",
83
+ "epistemic_tag": "Fact|Interpretation|Hypothesis|Conflict_Hypothesis",
84
+ "confidence": 0.0-1.0,
85
+ "missing_fields": ["field1", "field2"],
86
+ "status": "Complete|Incomplete"
87
+ }
88
+ ]
89
+ Output MUST be valid JSON array only. No explanations, no markdown.""",
90
+ }
91
+
92
+
93
+ # ============================================================
94
+ # Council Data Structures
95
+ # ============================================================
96
+
97
+ @dataclass
98
+ class CouncilRound:
99
+ """One complete council deliberation round."""
100
+ round_id: str
101
+ input_text: str
102
+ query_plan: list # Query Planner output
103
+ raw_extraction: list # Extractor output
104
+ critique: dict # Critic output
105
+ final_claims: list # Chairman output
106
+ metadata: dict = field(default_factory=dict)
107
+ started_at: str = ""
108
+ completed_at: str = ""
109
+ total_tokens: int = 0
110
+ total_cost_usd: float = 0.0
111
+
112
+ def to_dict(self):
113
+ return asdict(self)
114
+
115
+
116
+ @dataclass
117
+ class CouncilMemberResult:
118
+ """Result from a single council member."""
119
+ role: str
120
+ success: bool
121
+ data: any
122
+ raw_output: str = ""
123
+ tokens_in: int = 0
124
+ tokens_out: int = 0
125
+ latency_ms: int = 0
126
+ error: str = ""
127
+
128
+
129
+ # ============================================================
130
+ # The AI Model Council
131
+ # ============================================================
132
+
133
+ class ModelCouncil:
134
+ """
135
+ The AI Model Council — the final stage of the Research OS.
136
+
137
+ A 4-member council that processes scientific text through structured debate:
138
+ 1. Query Planner: Decomposes complex questions into search queries
139
+ 2. Extractor: Extracts atomic claims with epistemic tags
140
+ 3. Critic: Reviews and challenges the extraction
141
+ 4. Chairman: Synthesizes final claims with completeness penalties
142
+
143
+ The council produces higher-quality extractions than single-agent by:
144
+ - Catching hallucinations (critic checks against source text)
145
+ - Correcting epistemic tags (critic flags misclassifications)
146
+ - Applying completeness penalties (chairman enforces 0.7 penalty)
147
+ - Identifying missed claims (critic finds gaps)
148
+
149
+ All output is Provenance Level 5. Human review required.
150
+
151
+ Usage:
152
+ council = ModelCouncil(brain=brain)
153
+ result = council.deliberate("scientific text here...")
154
+ claims = result.final_claims # Ready for DB storage
155
+ """
156
+
157
+ def __init__(self, brain=None, db_path: str = None,
158
+ taxonomy_domain: str = "quantum_bio"):
159
+ """
160
+ Initialize the Model Council.
161
+
162
+ Args:
163
+ brain: ResearchOSBrain instance for LLM calls
164
+ db_path: Database path for logging
165
+ taxonomy_domain: Which taxonomy domain to use for scoring
166
+ """
167
+ self.brain = brain
168
+ self.db_path = db_path or os.environ.get("RESEARCH_OS_DB", "data/research_os.db")
169
+ self.taxonomy = TaxonomyManager(db_path=self.db_path)
170
+ self.taxonomy_domain = taxonomy_domain
171
+
172
+ # ============================================================
173
+ # Council Deliberation — The Main Pipeline
174
+ # ============================================================
175
+
176
+ def deliberate(self, text: str, query: str = None) -> CouncilRound:
177
+ """
178
+ Run a full council deliberation on scientific text.
179
+
180
+ Pipeline: Query Plan → Extract → Critique → Synthesize
181
+
182
+ Args:
183
+ text: Scientific paper text to extract claims from
184
+ query: Optional research question for query planning
185
+
186
+ Returns:
187
+ CouncilRound with all stages and final claims
188
+ """
189
+ round_id = gen_id("CNCL")
190
+ started = now_iso()
191
+ total_tokens = 0
192
+ total_cost = 0.0
193
+
194
+ # Stage 1: Query Planner (optional — only if query provided)
195
+ query_plan = []
196
+ if query:
197
+ planner_result = self._call_member("query_planner",
198
+ f"User question: {query}\nJSON Output:")
199
+ if planner_result.success:
200
+ query_plan = planner_result.data if isinstance(planner_result.data, list) else []
201
+ total_tokens += planner_result.tokens_in + planner_result.tokens_out
202
+
203
+ # Stage 2: Extractor
204
+ extractor_result = self._call_member("extractor",
205
+ f"Extract claims from the following scientific text:\n\n{text}")
206
+
207
+ raw_extraction = []
208
+ if extractor_result.success:
209
+ raw_extraction = extractor_result.data if isinstance(extractor_result.data, list) else []
210
+ total_tokens += extractor_result.tokens_in + extractor_result.tokens_out
211
+
212
+ # Stage 3: Critic (reviews extraction against original text)
213
+ critique = {}
214
+ if raw_extraction:
215
+ critic_input = (
216
+ f"Original text:\n{text}\n\n"
217
+ f"Extracted claims:\n{json.dumps(raw_extraction, indent=2)}\n\n"
218
+ f"Review these claims against the original text."
219
+ )
220
+ critic_result = self._call_member("critic", critic_input)
221
+ if critic_result.success:
222
+ critique = critic_result.data if isinstance(critic_result.data, dict) else {}
223
+ total_tokens += critic_result.tokens_in + critic_result.tokens_out
224
+
225
+ # Stage 4: Chairman (synthesizes final claims)
226
+ chairman_input = (
227
+ f"Original text:\n{text[:2000]}\n\n"
228
+ f"Extracted claims:\n{json.dumps(raw_extraction, indent=2)}\n\n"
229
+ f"Critic feedback:\n{json.dumps(critique, indent=2)}\n\n"
230
+ f"Synthesize the final claims. Apply 0.7 completeness penalty where needed."
231
+ )
232
+ chairman_result = self._call_member("chairman", chairman_input)
233
+
234
+ final_claims = []
235
+ if chairman_result.success:
236
+ final_claims = chairman_result.data if isinstance(chairman_result.data, list) else []
237
+ total_tokens += chairman_result.tokens_in + chairman_result.tokens_out
238
+
239
+ # Post-process: Apply taxonomy-aware confidence scoring
240
+ final_claims = self._apply_taxonomy_scoring(final_claims)
241
+
242
+ # Validate all claims have required fields
243
+ final_claims = self._validate_claims(final_claims)
244
+
245
+ round_result = CouncilRound(
246
+ round_id=round_id,
247
+ input_text=text[:500] + "..." if len(text) > 500 else text,
248
+ query_plan=query_plan,
249
+ raw_extraction=raw_extraction,
250
+ critique=critique,
251
+ final_claims=final_claims,
252
+ metadata={
253
+ "council_version": "1.0",
254
+ "taxonomy_domain": self.taxonomy_domain,
255
+ "extractor_claim_count": len(raw_extraction),
256
+ "critic_corrections": len(critique.get("tag_corrections", {})),
257
+ "critic_missing_claims": len(critique.get("missing_claims", [])),
258
+ "final_claim_count": len(final_claims),
259
+ },
260
+ started_at=started,
261
+ completed_at=now_iso(),
262
+ total_tokens=total_tokens,
263
+ total_cost_usd=total_cost,
264
+ )
265
+
266
+ # Log to DB
267
+ self._log_council_round(round_result)
268
+
269
+ return round_result
270
+
271
+ def deliberate_query(self, query: str) -> list:
272
+ """
273
+ Just run the Query Planner to decompose a complex question.
274
+ Returns list of sub-queries.
275
+ """
276
+ result = self._call_member("query_planner",
277
+ f"User question: {query}\nJSON Output:")
278
+ if result.success and isinstance(result.data, list):
279
+ return result.data
280
+ return [query] # Fallback: return original query
281
+
282
+ # ============================================================
283
+ # Council Member Calls
284
+ # ============================================================
285
+
286
+ def _call_member(self, role: str, user_message: str) -> CouncilMemberResult:
287
+ """
288
+ Call a single council member.
289
+ Uses the brain's API backend for LLM inference.
290
+ """
291
+ system_prompt = COUNCIL_PROMPTS.get(role, "")
292
+ messages = [
293
+ {"role": "system", "content": system_prompt},
294
+ {"role": "user", "content": user_message},
295
+ ]
296
+
297
+ start_time = time.time()
298
+
299
+ if self.brain is None:
300
+ return self._mock_member(role, user_message)
301
+
302
+ try:
303
+ if self.brain.backend == "local":
304
+ raw = self.brain._generate_local(messages)
305
+ else:
306
+ raw = self.brain._generate_api(messages)
307
+
308
+ latency = int((time.time() - start_time) * 1000)
309
+
310
+ # Parse JSON
311
+ text = raw.strip()
312
+ if text.startswith("```"):
313
+ parts = text.split("```")
314
+ text = parts[1] if len(parts) > 1 else text
315
+ if text.startswith("json"):
316
+ text = text[4:]
317
+ text = text.strip()
318
+
319
+ data = json.loads(text)
320
+
321
+ return CouncilMemberResult(
322
+ role=role,
323
+ success=True,
324
+ data=data,
325
+ raw_output=raw,
326
+ latency_ms=latency,
327
+ )
328
+
329
+ except json.JSONDecodeError as e:
330
+ return CouncilMemberResult(
331
+ role=role, success=False, data={},
332
+ raw_output=raw if 'raw' in dir() else "",
333
+ error=f"Invalid JSON from {role}: {str(e)}",
334
+ latency_ms=int((time.time() - start_time) * 1000),
335
+ )
336
+ except Exception as e:
337
+ return CouncilMemberResult(
338
+ role=role, success=False, data={},
339
+ error=f"{role} error: {str(e)}",
340
+ latency_ms=int((time.time() - start_time) * 1000),
341
+ )
342
+
343
+ def _mock_member(self, role: str, user_message: str) -> CouncilMemberResult:
344
+ """
345
+ Mock council member when no brain is available.
346
+ Produces structurally valid output for testing.
347
+ """
348
+ if role == "query_planner":
349
+ # Extract a query-like string from the input
350
+ data = ["general query from input text"]
351
+ elif role == "extractor":
352
+ data = [
353
+ {
354
+ "text": "Mock extracted claim from input text",
355
+ "epistemic_tag": "Interpretation",
356
+ "confidence": 0.5,
357
+ "missing_fields": ["sample_size", "p_value"],
358
+ "status": "Incomplete",
359
+ }
360
+ ]
361
+ elif role == "critic":
362
+ data = {
363
+ "feedback": "Mock critique: claims need more specificity",
364
+ "missing_claims": [],
365
+ "tag_corrections": {},
366
+ "confidence_adjustments": {},
367
+ "missing_field_suggestions": {},
368
+ }
369
+ elif role == "chairman":
370
+ data = [
371
+ {
372
+ "text": "Mock final claim synthesized by chairman",
373
+ "epistemic_tag": "Interpretation",
374
+ "confidence": 0.35, # 0.5 × 0.7 penalty
375
+ "missing_fields": ["sample_size", "p_value"],
376
+ "status": "Incomplete",
377
+ }
378
+ ]
379
+ else:
380
+ data = {}
381
+
382
+ return CouncilMemberResult(
383
+ role=role, success=True, data=data,
384
+ raw_output=json.dumps(data),
385
+ )
386
+
387
+ # ============================================================
388
+ # Post-Processing
389
+ # ============================================================
390
+
391
+ def _apply_taxonomy_scoring(self, claims: list) -> list:
392
+ """Apply taxonomy-aware confidence scoring to each claim."""
393
+ for claim in claims:
394
+ if not isinstance(claim, dict):
395
+ continue
396
+
397
+ # If claim has missing fields and status is Incomplete, apply 0.7 penalty
398
+ missing = claim.get("missing_fields", [])
399
+ status = claim.get("status", "Complete")
400
+ conf = float(claim.get("confidence", 0.5))
401
+
402
+ if missing and status == "Incomplete":
403
+ # Chairman should have already applied this, but enforce it
404
+ # Check if penalty was already applied (conf should be ≤ original × 0.7)
405
+ pass # Trust chairman's application
406
+
407
+ # Clamp confidence to [0, 1]
408
+ claim["confidence"] = max(0.0, min(1.0, round(conf, 3)))
409
+
410
+ # Ensure valid epistemic tag
411
+ valid_tags = ["Fact", "Interpretation", "Hypothesis", "Conflict_Hypothesis"]
412
+ if claim.get("epistemic_tag") not in valid_tags:
413
+ claim["epistemic_tag"] = "Interpretation" # Conservative default
414
+
415
+ # Ensure status consistency
416
+ if missing:
417
+ claim["status"] = "Incomplete"
418
+ elif not missing and claim.get("status") != "Complete":
419
+ claim["status"] = "Complete"
420
+
421
+ return claims
422
+
423
+ def _validate_claims(self, claims: list) -> list:
424
+ """Validate and clean all claims to match the required schema."""
425
+ validated = []
426
+ for claim in claims:
427
+ if not isinstance(claim, dict):
428
+ continue
429
+ if not claim.get("text"):
430
+ continue
431
+
432
+ validated.append({
433
+ "text": str(claim.get("text", "")),
434
+ "epistemic_tag": claim.get("epistemic_tag", "Interpretation"),
435
+ "confidence": max(0.0, min(1.0, float(claim.get("confidence", 0.5)))),
436
+ "missing_fields": claim.get("missing_fields", []) if isinstance(claim.get("missing_fields"), list) else [],
437
+ "status": claim.get("status", "Complete"),
438
+ })
439
+ return validated
440
+
441
+ # ============================================================
442
+ # Logging
443
+ # ============================================================
444
+
445
+ def _log_council_round(self, round_result: CouncilRound):
446
+ """Log a council round to the database."""
447
+ try:
448
+ conn = get_db(self.db_path)
449
+
450
+ # Ensure council_rounds table exists
451
+ conn.execute("""
452
+ CREATE TABLE IF NOT EXISTS council_rounds (
453
+ round_id TEXT PRIMARY KEY,
454
+ input_text TEXT,
455
+ query_plan TEXT,
456
+ raw_extraction_count INTEGER,
457
+ critique_summary TEXT,
458
+ final_claim_count INTEGER,
459
+ total_tokens INTEGER,
460
+ total_cost_usd REAL,
461
+ metadata TEXT,
462
+ started_at TEXT,
463
+ completed_at TEXT
464
+ )
465
+ """)
466
+
467
+ conn.execute("""
468
+ INSERT INTO council_rounds (round_id, input_text, query_plan,
469
+ raw_extraction_count, critique_summary, final_claim_count,
470
+ total_tokens, total_cost_usd, metadata, started_at, completed_at)
471
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
472
+ """, (
473
+ round_result.round_id,
474
+ round_result.input_text[:500],
475
+ json.dumps(round_result.query_plan),
476
+ len(round_result.raw_extraction),
477
+ round_result.critique.get("feedback", "")[:500] if isinstance(round_result.critique, dict) else "",
478
+ len(round_result.final_claims),
479
+ round_result.total_tokens,
480
+ round_result.total_cost_usd,
481
+ json.dumps(round_result.metadata),
482
+ round_result.started_at,
483
+ round_result.completed_at,
484
+ ))
485
+ conn.commit()
486
+ conn.close()
487
+ except Exception:
488
+ pass # Non-critical — don't fail extraction on logging error
489
+
490
+ def get_council_history(self, limit: int = 20) -> list:
491
+ """Get recent council deliberation rounds."""
492
+ try:
493
+ conn = get_db(self.db_path)
494
+ conn.execute("""
495
+ CREATE TABLE IF NOT EXISTS council_rounds (
496
+ round_id TEXT PRIMARY KEY,
497
+ input_text TEXT, query_plan TEXT,
498
+ raw_extraction_count INTEGER, critique_summary TEXT,
499
+ final_claim_count INTEGER, total_tokens INTEGER,
500
+ total_cost_usd REAL, metadata TEXT,
501
+ started_at TEXT, completed_at TEXT
502
+ )
503
+ """)
504
+ rows = conn.execute(
505
+ "SELECT * FROM council_rounds ORDER BY started_at DESC LIMIT ?",
506
+ (limit,)
507
+ ).fetchall()
508
+ conn.close()
509
+ results = []
510
+ for r in rows:
511
+ d = dict(r)
512
+ d["query_plan"] = json.loads(d.get("query_plan", "[]"))
513
+ d["metadata"] = json.loads(d.get("metadata", "{}"))
514
+ results.append(d)
515
+ return results
516
+ except Exception:
517
+ return []