tru8-mcp 1.0.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .env
tru8_mcp-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tru8
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: tru8-mcp
3
+ Version: 1.0.0
4
+ Summary: Tru8 Evidence Research MCP server — structured evidence research tools for AI agents
5
+ Project-URL: Homepage, https://www.trueight.com/developers
6
+ Project-URL: Repository, https://github.com/SamYatesSmith/tru8-mcp
7
+ Project-URL: Documentation, https://www.trueight.com/developers
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-agent,evidence,fact-check,mcp,research
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx>=0.25.0
18
+ Requires-Dist: mcp>=1.0.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # tru8-mcp
22
+
23
+ MCP server for [Tru8 Evidence Research](https://www.trueight.com) — structured evidence research tools for AI agents.
24
+
25
+ Submit a claim or URL, get back source-traced evidence organized by tier (primary/reporting/commentary) and type (data/official/news/analysis/opinion/academic), with element decomposition and relationship mapping.
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ pip install tru8-mcp
31
+ ```
32
+
33
+ Or run without installing:
34
+
35
+ ```bash
36
+ uvx tru8-mcp
37
+ ```
38
+
39
+ Set your API key:
40
+
41
+ ```bash
42
+ export TRU8_API_KEY=tru8_sk_...
43
+ ```
44
+
45
+ Create an API key at your [Tru8 dashboard](https://www.trueight.com/dashboard/settings) under Settings > Developer.
46
+
47
+ ## Configuration
48
+
49
+ ### Claude Desktop
50
+
51
+ Add to `claude_desktop_config.json`:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "tru8": {
57
+ "command": "tru8-mcp",
58
+ "env": {
59
+ "TRU8_API_KEY": "tru8_sk_..."
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### Cline / Cursor
67
+
68
+ Add to your MCP settings:
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "tru8": {
74
+ "command": "uvx",
75
+ "args": ["tru8-mcp"],
76
+ "env": {
77
+ "TRU8_API_KEY": "tru8_sk_..."
78
+ }
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### From source
85
+
86
+ ```bash
87
+ git clone https://github.com/tru8-evidence/tru8-mcp.git
88
+ cd tru8-mcp
89
+ pip install -e .
90
+ tru8-mcp
91
+ ```
92
+
93
+ ## Tools
94
+
95
+ | Tool | Description | Typical time |
96
+ |------|-------------|-------------|
97
+ | `tru8_check` | Evidence research for a claim or article URL | 15-120s |
98
+ | `tru8_get_result` | Retrieve completed check with computed analytics | <1s |
99
+ | `tru8_get_result_raw` | Retrieve raw check data without computed analytics | <1s |
100
+
101
+ ## Environment Variables
102
+
103
+ | Variable | Required | Description |
104
+ |----------|----------|-------------|
105
+ | `TRU8_API_KEY` | Yes | API key (`tru8_sk_...`). Create at dashboard > Settings > Developer. |
106
+ | `TRU8_API_URL` | No | API base URL. Default: `https://api.trueight.com` |
107
+
108
+ ## Security
109
+
110
+ Store API keys in environment variables or a secrets manager. Never hardcode keys in source code. If a key is compromised, revoke it immediately at your dashboard.
@@ -0,0 +1,90 @@
1
+ # tru8-mcp
2
+
3
+ MCP server for [Tru8 Evidence Research](https://www.trueight.com) — structured evidence research tools for AI agents.
4
+
5
+ Submit a claim or URL, get back source-traced evidence organized by tier (primary/reporting/commentary) and type (data/official/news/analysis/opinion/academic), with element decomposition and relationship mapping.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ pip install tru8-mcp
11
+ ```
12
+
13
+ Or run without installing:
14
+
15
+ ```bash
16
+ uvx tru8-mcp
17
+ ```
18
+
19
+ Set your API key:
20
+
21
+ ```bash
22
+ export TRU8_API_KEY=tru8_sk_...
23
+ ```
24
+
25
+ Create an API key at your [Tru8 dashboard](https://www.trueight.com/dashboard/settings) under Settings > Developer.
26
+
27
+ ## Configuration
28
+
29
+ ### Claude Desktop
30
+
31
+ Add to `claude_desktop_config.json`:
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "tru8": {
37
+ "command": "tru8-mcp",
38
+ "env": {
39
+ "TRU8_API_KEY": "tru8_sk_..."
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### Cline / Cursor
47
+
48
+ Add to your MCP settings:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "tru8": {
54
+ "command": "uvx",
55
+ "args": ["tru8-mcp"],
56
+ "env": {
57
+ "TRU8_API_KEY": "tru8_sk_..."
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### From source
65
+
66
+ ```bash
67
+ git clone https://github.com/tru8-evidence/tru8-mcp.git
68
+ cd tru8-mcp
69
+ pip install -e .
70
+ tru8-mcp
71
+ ```
72
+
73
+ ## Tools
74
+
75
+ | Tool | Description | Typical time |
76
+ |------|-------------|-------------|
77
+ | `tru8_check` | Evidence research for a claim or article URL | 15-120s |
78
+ | `tru8_get_result` | Retrieve completed check with computed analytics | <1s |
79
+ | `tru8_get_result_raw` | Retrieve raw check data without computed analytics | <1s |
80
+
81
+ ## Environment Variables
82
+
83
+ | Variable | Required | Description |
84
+ |----------|----------|-------------|
85
+ | `TRU8_API_KEY` | Yes | API key (`tru8_sk_...`). Create at dashboard > Settings > Developer. |
86
+ | `TRU8_API_URL` | No | API base URL. Default: `https://api.trueight.com` |
87
+
88
+ ## Security
89
+
90
+ Store API keys in environment variables or a secrets manager. Never hardcode keys in source code. If a key is compromised, revoke it immediately at your dashboard.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tru8-mcp"
7
+ version = "1.0.0"
8
+ description = "Tru8 Evidence Research MCP server — structured evidence research tools for AI agents"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ keywords = ["mcp", "evidence", "fact-check", "ai-agent", "research"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Scientific/Engineering :: Information Analysis",
19
+ ]
20
+ dependencies = [
21
+ "mcp>=1.0.0",
22
+ "httpx>=0.25.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://www.trueight.com/developers"
27
+ Repository = "https://github.com/SamYatesSmith/tru8-mcp"
28
+ Documentation = "https://www.trueight.com/developers"
29
+
30
+ [project.scripts]
31
+ tru8-mcp = "tru8_mcp.server:main"
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.tru8/mcp-server",
4
+ "title": "Tru8 Evidence Research",
5
+ "description": "Structured evidence research for AI agents. Ground factual claims in source-traced evidence organized by tier (primary data / news reporting / commentary) and type (data / official / news / analysis / opinion / academic). Returns element decomposition with relationship mapping (supports / challenges / context).",
6
+ "version": "1.0.0",
7
+ "websiteUrl": "https://www.trueight.com/developers",
8
+ "repository": {
9
+ "url": "https://github.com/SamYatesSmith/tru8-mcp",
10
+ "source": "github"
11
+ },
12
+ "packages": [
13
+ {
14
+ "_note_package": "Publish to PyPI before submitting to MCP Registry",
15
+ "registryType": "pip",
16
+ "identifier": "tru8-mcp",
17
+ "version": "1.0.0",
18
+ "transport": {
19
+ "type": "stdio"
20
+ },
21
+ "packageArguments": [],
22
+ "environmentVariables": [
23
+ {
24
+ "name": "TRU8_API_KEY",
25
+ "isRequired": true,
26
+ "isSecret": true,
27
+ "description": "Tru8 API key (tru8_sk_...). Create at dashboard Settings > Developer. Store in env vars, never hardcode."
28
+ },
29
+ {
30
+ "name": "TRU8_API_URL",
31
+ "isRequired": false,
32
+ "isSecret": false,
33
+ "description": "API base URL. Default: https://api.trueight.com"
34
+ }
35
+ ],
36
+ "runtimeHint": "python -m tru8_mcp"
37
+ }
38
+ ],
39
+ "tools": [
40
+ {
41
+ "name": "tru8_check",
42
+ "description": "Evidence research for any claim or URL. Accepts max_tier (lookup/quick/full) to control depth and cost. Tier fallback: lookup → quick → full. Returns structured evidence landscape with element decomposition, source classification, and _meta (executedTier, chargedPence)."
43
+ },
44
+ {
45
+ "name": "tru8_get_result",
46
+ "description": "Retrieve a completed check with pre-computed analytics: tier/type distributions, corroboration groups, diagnostic values, timeline, per-claim dispositions."
47
+ },
48
+ {
49
+ "name": "tru8_get_result_raw",
50
+ "description": "Retrieve raw check data without computed analytics. Smaller payload for custom post-processing."
51
+ }
52
+ ]
53
+ }
@@ -0,0 +1,23 @@
1
+ # Smithery configuration — https://smithery.ai/docs/config
2
+ # Tru8 MCP server: structured evidence research for AI agents
3
+
4
+ build:
5
+ dockerBuildPath: .
6
+
7
+ startCommand:
8
+ type: stdio
9
+ configSchema:
10
+ type: object
11
+ required:
12
+ - apiKey
13
+ properties:
14
+ apiKey:
15
+ type: string
16
+ description: "Tru8 API key (tru8_sk_...). Create at dashboard → Settings → Developer."
17
+ apiUrl:
18
+ type: string
19
+ description: "API base URL. Default: https://api.trueight.com"
20
+ default: "https://api.trueight.com"
21
+ commandFunction:
22
+ |-
23
+ (config) => ({ command: 'python', args: ['-m', 'tru8_mcp'], env: { TRU8_API_KEY: config.apiKey, TRU8_API_URL: config.apiUrl || 'https://api.trueight.com' } })
@@ -0,0 +1 @@
1
+ """Tru8 MCP server — structured evidence research tools for AI agents."""
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m tru8_mcp`."""
2
+
3
+ from .server import main
4
+
5
+ main()
@@ -0,0 +1,149 @@
1
+ """Tru8 MCP server — structured evidence research tools for AI agents.
2
+
3
+ Run standalone:
4
+ python -m tru8_mcp
5
+
6
+ Configure for Claude Desktop (claude_desktop_config.json):
7
+ {
8
+ "mcpServers": {
9
+ "tru8": {
10
+ "command": "python",
11
+ "args": ["-m", "tru8_mcp"],
12
+ "env": {"TRU8_API_KEY": "tru8_sk_..."}
13
+ }
14
+ }
15
+ }
16
+
17
+ Environment variables:
18
+ TRU8_API_KEY — Required. Create at dashboard → Settings → Developer.
19
+ Store in env vars or a secrets manager — never hardcode in source.
20
+ TRU8_API_URL — Optional. Default: https://api.trueight.com
21
+ """
22
+
23
+ import json
24
+
25
+ from mcp.server.fastmcp import FastMCP
26
+
27
+ from .tools import Tru8APIClient
28
+
29
+ mcp = FastMCP(
30
+ "tru8",
31
+ instructions=(
32
+ "Structured evidence research. Submit a factual claim or article URL. "
33
+ "Evidence is organised by source tier (primary/reporting/commentary) "
34
+ "and type (data/official/news/analysis/opinion/academic), with element "
35
+ "decomposition and relationship mapping (supports/challenges/context)."
36
+ ),
37
+ )
38
+
39
+ _client: Tru8APIClient | None = None
40
+
41
+
42
+ def _get_client() -> Tru8APIClient:
43
+ """Lazy-initialize the API client (reads env vars on first call)."""
44
+ global _client
45
+ if _client is None:
46
+ _client = Tru8APIClient()
47
+ return _client
48
+
49
+
50
+ def _format(data: dict) -> str:
51
+ """Serialize API response as indented JSON for agent consumption."""
52
+ return json.dumps(data, indent=2, default=str)
53
+
54
+
55
+ @mcp.tool()
56
+ async def tru8_check(
57
+ claim: str,
58
+ max_tier: str = "quick",
59
+ max_age_hours: int | None = None,
60
+ compact: bool = False,
61
+ ) -> str:
62
+ """Evidence research for a factual claim or article URL.
63
+
64
+ Submit a claim as text or paste an article URL. URLs are auto-detected
65
+ and trigger article mode: the pipeline extracts claims from the page
66
+ and auto-selects up to 5 for evidence research.
67
+
68
+ Tiers (in fallback order):
69
+ - lookup (~£0.02, instant) — cached prior analysis
70
+ - consensus (~£0.03, instant) — cross-user aggregate landscape (k≥3 checks)
71
+ - quick (~£0.07, ~15s) — web search + heuristic classification
72
+ - full (~£0.15, ~60-90s) — 30+ sources, LLM classification, coverage recovery
73
+
74
+ Charges based on tier actually executed, not tier requested.
75
+ Set max_tier to control maximum spend per call.
76
+
77
+ Output structure:
78
+ - claims[].claimMap.elements[] — verifiable sub-claims with state
79
+ (supported/disputed/unresolved)
80
+ - claims[].claimMap.elements[].evidenceRefs[] — evidence mapped to elements
81
+ with relationship (supports/challenges/context) and reasoning
82
+ - claims[].evidence[] — sources classified by tier (primary/reporting/
83
+ commentary) and type (data/official/news/analysis/opinion/academic)
84
+ - claims[].claimMap.orientation — mechanical summary from element states
85
+ - _meta — execution metadata: executedTier, chargedPence, limitations
86
+
87
+ Args:
88
+ claim: A factual claim ("The Earth's average temperature rose 1.1°C
89
+ since 1880") or an article URL (https://example.com/article).
90
+ URLs are auto-detected and the pipeline extracts claims from
91
+ the page content.
92
+ max_tier: Maximum tier to attempt — "lookup", "consensus", "quick" (default), or "full".
93
+ max_age_hours: Skip cache hits older than this many hours. If set,
94
+ lookup hits that are stale will be discarded and the
95
+ pipeline re-runs at the next tier up to max_tier.
96
+ compact: If True, strip full evidence arrays from response (smaller payload).
97
+ """
98
+ client = _get_client()
99
+ result = await client.submit_with_fallback(
100
+ claim,
101
+ max_tier=max_tier,
102
+ max_age_hours=max_age_hours,
103
+ compact=compact,
104
+ )
105
+ return _format(result)
106
+
107
+
108
+ @mcp.tool()
109
+ async def tru8_get_result(check_id: str) -> str:
110
+ """Retrieve a previously submitted check with pre-computed analytics.
111
+
112
+ Returns the full result including a _computed block with tier/type
113
+ distributions, corroboration groups, diagnostic values, timeline
114
+ analysis, element state summaries, and per-claim dispositions.
115
+
116
+ Use this over tru8_get_result_raw when you want structured analytics
117
+ ready for summarisation or comparison without post-processing.
118
+
119
+ Args:
120
+ check_id: UUID returned by tru8_check.
121
+ """
122
+ client = _get_client()
123
+ result = await client.get_check(check_id, computed=True)
124
+ return _format(result)
125
+
126
+
127
+ @mcp.tool()
128
+ async def tru8_get_result_raw(check_id: str) -> str:
129
+ """Retrieve a previously submitted check without computed analytics.
130
+
131
+ Returns claims, elements, evidence, and claim maps only. No _computed
132
+ block. Smaller response payload. Use this when you will compute your
133
+ own aggregations or only need specific fields from the raw data.
134
+
135
+ Args:
136
+ check_id: UUID returned by tru8_check.
137
+ """
138
+ client = _get_client()
139
+ result = await client.get_check(check_id, computed=False)
140
+ return _format(result)
141
+
142
+
143
+ def main():
144
+ """Entry point for `python -m tru8_mcp`."""
145
+ mcp.run()
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -0,0 +1,389 @@
1
+ """Tru8 API client for MCP tool implementations.
2
+
3
+ Wraps the Tru8 REST API with methods for submitting checks via the
4
+ synchronous /run endpoint (preferred) or agent tier endpoints (L-08).
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ from typing import Optional
12
+
13
+ import httpx
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Pipeline timeouts (seconds)
18
+ PIPELINE_TIMEOUT = 180.0
19
+ GET_TIMEOUT = 30.0
20
+ POLL_INTERVAL = 3.0
21
+ MAX_POLL_DURATION = 300.0
22
+
23
+ # Tier timeouts — quick is faster, so shorter HTTP timeout
24
+ TIER_TIMEOUTS = {
25
+ "lookup": 15.0,
26
+ "consensus": 15.0,
27
+ "quick": 60.0,
28
+ "full": PIPELINE_TIMEOUT + 30.0,
29
+ }
30
+
31
+ # Tier order for fallback
32
+ TIER_ORDER = ["lookup", "consensus", "quick", "full"]
33
+
34
+
35
+ class Tru8APIClient:
36
+ """HTTP client wrapping the Tru8 Evidence Research API.
37
+
38
+ Authenticates via API key (X-API-Key header). Supports both the legacy
39
+ /checks/run endpoint and the agent tier endpoints (/agent/lookup, /quick, /full).
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ api_url: Optional[str] = None,
45
+ api_key: Optional[str] = None,
46
+ ):
47
+ self.base_url = (
48
+ api_url or os.environ.get("TRU8_API_URL", "https://api.trueight.com")
49
+ ).rstrip("/")
50
+ self.api_key = api_key or os.environ.get("TRU8_API_KEY", "")
51
+ if not self.api_key:
52
+ raise ValueError(
53
+ "TRU8_API_KEY environment variable required. "
54
+ "Create one at your Tru8 dashboard → Settings → Developer."
55
+ )
56
+
57
+ def _headers(self) -> dict:
58
+ return {"X-API-Key": self.api_key, "Accept": "application/json"}
59
+
60
+ @staticmethod
61
+ def _detect_input_type(claim: str) -> Optional[str]:
62
+ """Return "url" if claim looks like a URL, else None (server auto-detects)."""
63
+ if claim.strip().startswith(("http://", "https://")):
64
+ return "url"
65
+ return None
66
+
67
+ async def submit_tier(
68
+ self,
69
+ claim: str,
70
+ tier: str,
71
+ compact: bool = False,
72
+ ) -> dict:
73
+ """Submit a claim or URL to a specific agent tier endpoint.
74
+
75
+ Args:
76
+ claim: The claim text or article URL to analyse.
77
+ tier: "lookup", "quick", or "full".
78
+ compact: If True, strip evidence arrays from response.
79
+
80
+ Returns:
81
+ The tier endpoint response dict (includes _meta block).
82
+ """
83
+ timeout = TIER_TIMEOUTS.get(tier, PIPELINE_TIMEOUT)
84
+ payload: dict = {"claim": claim, "compact": compact}
85
+ input_type = self._detect_input_type(claim)
86
+ if input_type:
87
+ payload["input_type"] = input_type
88
+
89
+ async with httpx.AsyncClient(
90
+ timeout=httpx.Timeout(timeout, connect=10.0)
91
+ ) as client:
92
+ resp = await client.post(
93
+ f"{self.base_url}/api/v1/agent/{tier}",
94
+ json=payload,
95
+ headers=self._headers(),
96
+ )
97
+ if resp.status_code == 402:
98
+ raise InsufficientBalanceError(f"Insufficient balance for {tier} tier")
99
+ if resp.status_code not in (200, 201):
100
+ raise RuntimeError(f"API error {resp.status_code}: {resp.text}")
101
+ return resp.json()
102
+
103
+ async def submit_smart(
104
+ self,
105
+ claim: str,
106
+ max_tier: str = "quick",
107
+ max_age_hours: Optional[int] = None,
108
+ compact: bool = False,
109
+ ) -> dict:
110
+ """Submit via smart endpoint with server-side fallback (M-03).
111
+
112
+ Args:
113
+ claim: The claim text or article URL to analyse.
114
+ max_tier: Maximum tier to attempt ("lookup", "quick", or "full").
115
+ max_age_hours: Skip cache hits older than this many hours.
116
+ compact: If True, strip evidence arrays from response.
117
+
118
+ Returns:
119
+ The smart endpoint response dict.
120
+ """
121
+ timeout = TIER_TIMEOUTS.get(max_tier, PIPELINE_TIMEOUT)
122
+ payload: dict = {"claim": claim, "max_tier": max_tier, "compact": compact}
123
+ input_type = self._detect_input_type(claim)
124
+ if input_type:
125
+ payload["input_type"] = input_type
126
+ if max_age_hours:
127
+ payload["max_age_hours"] = max_age_hours
128
+
129
+ async with httpx.AsyncClient(
130
+ timeout=httpx.Timeout(timeout, connect=10.0)
131
+ ) as client:
132
+ resp = await client.post(
133
+ f"{self.base_url}/api/v1/agent/check",
134
+ json=payload,
135
+ headers=self._headers(),
136
+ )
137
+ if resp.status_code == 402:
138
+ raise InsufficientBalanceError(
139
+ f"Insufficient balance for {max_tier} tier"
140
+ )
141
+ if resp.status_code not in (200, 201):
142
+ raise RuntimeError(f"API error {resp.status_code}: {resp.text}")
143
+ return resp.json()
144
+
145
+ async def submit_with_fallback(
146
+ self,
147
+ claim: str,
148
+ max_tier: str = "quick",
149
+ max_age_hours: Optional[int] = None,
150
+ compact: bool = False,
151
+ ) -> dict:
152
+ """Submit a claim with tier fallback: lookup → quick → full (up to max_tier).
153
+
154
+ Prefers the smart endpoint (M-03). Falls back to client-side tier
155
+ escalation if the server returns 404 (backward compat with older servers).
156
+
157
+ Args:
158
+ claim: The claim text to analyse.
159
+ max_tier: Maximum tier to attempt ("lookup", "quick", or "full").
160
+ max_age_hours: Skip cache hits older than this many hours.
161
+ compact: If True, strip evidence arrays from response.
162
+
163
+ Returns:
164
+ The response dict from whichever tier succeeded.
165
+ """
166
+ # Try smart endpoint first (M-03)
167
+ try:
168
+ return await self.submit_smart(
169
+ claim, max_tier=max_tier, max_age_hours=max_age_hours, compact=compact
170
+ )
171
+ except RuntimeError as e:
172
+ if "404" in str(e):
173
+ logger.info(
174
+ "Smart endpoint not available, falling back to client-side escalation"
175
+ )
176
+ else:
177
+ raise
178
+
179
+ # Client-side fallback for older servers
180
+ max_rank = TIER_ORDER.index(max_tier) if max_tier in TIER_ORDER else 1
181
+
182
+ # Always try lookup first
183
+ try:
184
+ result = await self.submit_tier(claim, "lookup", compact=compact)
185
+ if result.get("hit"):
186
+ return result
187
+ # Lookup miss — escalate if allowed
188
+ except InsufficientBalanceError:
189
+ raise
190
+ except Exception as e:
191
+ logger.warning(f"Lookup failed: {e}")
192
+
193
+ # Escalate to quick if allowed
194
+ if max_rank >= TIER_ORDER.index("quick"):
195
+ try:
196
+ return await self.submit_tier(claim, "quick", compact=compact)
197
+ except InsufficientBalanceError:
198
+ raise
199
+ except Exception as e:
200
+ if max_rank >= TIER_ORDER.index("full"):
201
+ logger.warning(f"Quick failed, escalating to full: {e}")
202
+ else:
203
+ raise
204
+
205
+ # Escalate to full if allowed
206
+ if max_rank >= TIER_ORDER.index("full"):
207
+ return await self.submit_tier(claim, "full", compact=compact)
208
+
209
+ raise RuntimeError(f"No tier up to {max_tier} returned a result")
210
+
211
+ async def submit_check_sync(self, text: str) -> dict:
212
+ """Submit content via the synchronous /run endpoint and return the full result.
213
+
214
+ Backward-compatible alias. For new code, prefer submit_with_fallback().
215
+ """
216
+ input_type = (
217
+ "url" if text.strip().startswith(("http://", "https://")) else "text"
218
+ )
219
+ payload = {"input_type": input_type}
220
+ if input_type == "url":
221
+ payload["url"] = text.strip()
222
+ else:
223
+ payload["content"] = text.strip()
224
+
225
+ async with httpx.AsyncClient(
226
+ timeout=httpx.Timeout(PIPELINE_TIMEOUT + 30.0, connect=10.0)
227
+ ) as client:
228
+ resp = await client.post(
229
+ f"{self.base_url}/api/v1/checks/run",
230
+ json=payload,
231
+ headers=self._headers(),
232
+ )
233
+ if resp.status_code != 200:
234
+ raise RuntimeError(f"API error {resp.status_code}: {resp.text}")
235
+ return resp.json()
236
+
237
+ async def submit_check_sse(self, text: str) -> str:
238
+ """Submit content for evidence research and wait for completion.
239
+
240
+ Consumes the SSE stream from POST /stream. For URL inputs that pause
241
+ for claim selection, auto-selects all extracted claims (up to 5) and
242
+ polls until Phase 2 completes.
243
+
244
+ Returns:
245
+ The completed check ID.
246
+ """
247
+ input_type = (
248
+ "url" if text.strip().startswith(("http://", "https://")) else "text"
249
+ )
250
+ payload = {"input_type": input_type}
251
+ if input_type == "url":
252
+ payload["url"] = text.strip()
253
+ else:
254
+ payload["content"] = text.strip()
255
+
256
+ timeout = PIPELINE_TIMEOUT
257
+ check_id = None
258
+ awaiting_selection = False
259
+ claim_positions = []
260
+
261
+ async with httpx.AsyncClient(
262
+ timeout=httpx.Timeout(timeout, connect=10.0)
263
+ ) as client:
264
+ async with client.stream(
265
+ "POST",
266
+ f"{self.base_url}/api/v1/checks/stream",
267
+ json=payload,
268
+ headers={**self._headers(), "Accept": "text/event-stream"},
269
+ ) as response:
270
+ if response.status_code != 200:
271
+ body = await response.aread()
272
+ raise RuntimeError(
273
+ f"API error {response.status_code}: {body.decode()}"
274
+ )
275
+
276
+ check_id = response.headers.get("x-check-id")
277
+
278
+ async for line in response.aiter_lines():
279
+ line = line.strip()
280
+ if not line or not line.startswith("data:"):
281
+ continue
282
+ try:
283
+ data = json.loads(line[5:].strip())
284
+ except json.JSONDecodeError:
285
+ continue
286
+
287
+ event_type = data.get("type")
288
+
289
+ if event_type == "completed":
290
+ check_id = check_id or data.get("checkId")
291
+ return check_id
292
+
293
+ elif event_type == "error":
294
+ raise RuntimeError(
295
+ f"Pipeline error: {data.get('error', 'unknown')}"
296
+ )
297
+
298
+ elif event_type == "timeout":
299
+ raise RuntimeError("Pipeline timed out")
300
+
301
+ elif event_type == "awaiting_selection":
302
+ check_id = check_id or data.get("checkId")
303
+ # Extract claim positions, preferring highest-ranked
304
+ claims = data.get("claims", [])
305
+ claims_sorted = sorted(
306
+ claims,
307
+ key=lambda c: c.get("significance_rank", 999),
308
+ )
309
+ claim_positions = [
310
+ c.get("position", i) for i, c in enumerate(claims_sorted)
311
+ ]
312
+ awaiting_selection = True
313
+ break
314
+
315
+ elif event_type == "connected":
316
+ check_id = check_id or data.get("checkId")
317
+
318
+ if not check_id:
319
+ raise RuntimeError("No check ID received from pipeline")
320
+
321
+ if awaiting_selection:
322
+ await self._select_claims(check_id, claim_positions[:5])
323
+ await self._poll_until_complete(check_id)
324
+
325
+ return check_id
326
+
327
+ async def _select_claims(self, check_id: str, positions: list[int]) -> None:
328
+ """Select claims for Phase 2 analysis."""
329
+ if not positions:
330
+ positions = [0]
331
+
332
+ async with httpx.AsyncClient(timeout=GET_TIMEOUT) as client:
333
+ resp = await client.patch(
334
+ f"{self.base_url}/api/v1/checks/{check_id}/select-claims",
335
+ json={"selected_positions": positions},
336
+ headers={**self._headers(), "Content-Type": "application/json"},
337
+ )
338
+ if resp.status_code not in (200, 202):
339
+ raise RuntimeError(
340
+ f"Claim selection failed ({resp.status_code}): {resp.text}"
341
+ )
342
+
343
+ logger.info(f"Auto-selected {len(positions)} claims for check {check_id}")
344
+
345
+ async def _poll_until_complete(self, check_id: str) -> None:
346
+ """Poll check status until completed or failed."""
347
+ elapsed = 0.0
348
+ async with httpx.AsyncClient(timeout=GET_TIMEOUT) as client:
349
+ while elapsed < MAX_POLL_DURATION:
350
+ resp = await client.get(
351
+ f"{self.base_url}/api/v1/checks/{check_id}",
352
+ headers=self._headers(),
353
+ )
354
+ if resp.status_code == 200:
355
+ status = resp.json().get("status")
356
+ if status == "completed":
357
+ return
358
+ elif status == "failed":
359
+ error = resp.json().get("errorMessage", "Pipeline failed")
360
+ raise RuntimeError(f"Pipeline failed: {error}")
361
+
362
+ await asyncio.sleep(POLL_INTERVAL)
363
+ elapsed += POLL_INTERVAL
364
+
365
+ raise RuntimeError(f"Pipeline did not complete within {MAX_POLL_DURATION}s")
366
+
367
+ async def get_check(self, check_id: str, computed: bool = False) -> dict:
368
+ """Retrieve a completed check by ID.
369
+
370
+ Args:
371
+ check_id: UUID of the check.
372
+ computed: If True, include _computed analytics block.
373
+ """
374
+ params = {"computed": "true"} if computed else {}
375
+ async with httpx.AsyncClient(timeout=GET_TIMEOUT) as client:
376
+ resp = await client.get(
377
+ f"{self.base_url}/api/v1/checks/{check_id}",
378
+ headers=self._headers(),
379
+ params=params,
380
+ )
381
+ if resp.status_code != 200:
382
+ raise RuntimeError(f"API error {resp.status_code}: {resp.text}")
383
+ return resp.json()
384
+
385
+
386
+ class InsufficientBalanceError(RuntimeError):
387
+ """Raised when the agent's credit balance is insufficient for the requested tier."""
388
+
389
+ pass