Root cause: Perplexity deck generation hit the 8192 max_tokens limit, truncating the JSON response mid-string at card ~70-80. json.loads() fails with 'Unterminated string' and the fallback also couldn't recover. Fixes: - Bump DECK_GEN_MAX_TOKENS from 8192 to 16384 (primary fix) - Add _repair_truncated_json() to PerplexityClient — if token limit is still hit, salvages all complete card objects from the truncated JSON instead of returning a 422 error - Add OUTPUT EFFICIENCY instructions to system prompt to reduce per-card verbosity (shorter reasons, fewer synergy_with entries) - Add 'truncated' flag to response so UI can warn if deck is incomplete - Log warning when recovered deck has <90 cards
380 lines
12 KiB
Python
380 lines
12 KiB
Python
"""
|
||
Commander AI Lab — Perplexity Sonar Structured Output Client
|
||
═════════════════════════════════════════════════════════════
|
||
Uses the openai SDK to call Perplexity's chat/completions endpoint
|
||
with response_format: json_schema for guaranteed structured output.
|
||
|
||
Supports both sonar (fast, cheap) and sonar-pro (deep, thorough).
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import asyncio
|
||
from typing import Optional
|
||
|
||
from openai import OpenAI
|
||
|
||
logger = logging.getLogger("coach.pplx")
|
||
|
||
# ── Perplexity Models ─────────────────────────────────────────
|
||
SONAR = "sonar"
|
||
SONAR_PRO = "sonar-pro"
|
||
SONAR_DEEP_RESEARCH = "sonar-deep-research"
|
||
|
||
|
||
class PerplexityResponse:
|
||
"""Parsed response container from Perplexity API."""
|
||
def __init__(self, content: str, parsed_json: Optional[dict],
|
||
prompt_tokens: int, completion_tokens: int,
|
||
model: str, citations: list[str] = None):
|
||
self.content = content
|
||
self.parsed_json = parsed_json
|
||
self.prompt_tokens = prompt_tokens
|
||
self.completion_tokens = completion_tokens
|
||
self.model = model
|
||
self.citations = citations or []
|
||
|
||
@property
|
||
def ok(self) -> bool:
|
||
return self.parsed_json is not None
|
||
|
||
|
||
class PerplexityClient:
|
||
"""
|
||
Perplexity Sonar API client using the openai SDK.
|
||
|
||
Usage:
|
||
client = PerplexityClient(api_key="pplx-...")
|
||
resp = client.chat_structured(
|
||
system_prompt="You are a deck builder.",
|
||
user_prompt="Build a deck for...",
|
||
json_schema=deck_schema,
|
||
schema_name="DeckList",
|
||
)
|
||
if resp.ok:
|
||
deck = resp.parsed_json
|
||
"""
|
||
|
||
def __init__(self, api_key: str, model: str = SONAR,
|
||
timeout: int = 120):
|
||
if not api_key:
|
||
raise ValueError("Perplexity API key is required")
|
||
self.api_key = api_key
|
||
self.model = model
|
||
self.timeout = timeout
|
||
self._client = OpenAI(
|
||
api_key=api_key,
|
||
base_url="https://api.perplexity.ai",
|
||
timeout=timeout,
|
||
)
|
||
|
||
def chat_structured(
|
||
self,
|
||
system_prompt: str,
|
||
user_prompt: str,
|
||
json_schema: dict,
|
||
schema_name: str = "response",
|
||
model: str = None,
|
||
temperature: float = 0.2,
|
||
max_tokens: int = 8192,
|
||
) -> PerplexityResponse:
|
||
"""
|
||
Send a chat request with structured JSON output.
|
||
|
||
Args:
|
||
system_prompt: System message
|
||
user_prompt: User message
|
||
json_schema: JSON Schema dict for response_format
|
||
schema_name: Name for the schema (used in response_format)
|
||
model: Override model (sonar, sonar-pro)
|
||
temperature: Sampling temperature (0.0–1.0)
|
||
max_tokens: Max output tokens
|
||
|
||
Returns:
|
||
PerplexityResponse with parsed_json guaranteed by schema
|
||
"""
|
||
use_model = model or self.model
|
||
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
]
|
||
|
||
try:
|
||
response = self._client.chat.completions.create(
|
||
model=use_model,
|
||
messages=messages,
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
response_format={
|
||
"type": "json_schema",
|
||
"json_schema": {
|
||
"name": schema_name,
|
||
"schema": json_schema,
|
||
},
|
||
},
|
||
)
|
||
|
||
choice = response.choices[0]
|
||
content = choice.message.content or ""
|
||
usage = response.usage
|
||
|
||
# Parse the structured JSON
|
||
parsed = None
|
||
try:
|
||
parsed = json.loads(content)
|
||
except (json.JSONDecodeError, TypeError) as e:
|
||
logger.warning("Failed to parse structured response: %s", e)
|
||
# Fallback: try extracting JSON object
|
||
parsed = self._extract_json(content)
|
||
|
||
# Extract citations if available
|
||
citations = []
|
||
if hasattr(response, 'citations') and response.citations:
|
||
citations = response.citations
|
||
|
||
logger.info(
|
||
"[pplx] model=%s tokens=(%d prompt, %d completion) parsed=%s",
|
||
use_model,
|
||
usage.prompt_tokens if usage else 0,
|
||
usage.completion_tokens if usage else 0,
|
||
"yes" if parsed else "no",
|
||
)
|
||
|
||
return PerplexityResponse(
|
||
content=content,
|
||
parsed_json=parsed,
|
||
prompt_tokens=usage.prompt_tokens if usage else 0,
|
||
completion_tokens=usage.completion_tokens if usage else 0,
|
||
model=use_model,
|
||
citations=citations,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error("[pplx] API call failed: %s", e)
|
||
raise
|
||
|
||
def chat_plain(
|
||
self,
|
||
system_prompt: str,
|
||
user_prompt: str,
|
||
model: str = None,
|
||
temperature: float = 0.2,
|
||
max_tokens: int = 4096,
|
||
) -> PerplexityResponse:
|
||
"""
|
||
Send a plain chat request without structured output.
|
||
Used for substitution fallback queries where schema
|
||
compliance is less critical.
|
||
"""
|
||
use_model = model or self.model
|
||
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
]
|
||
|
||
try:
|
||
response = self._client.chat.completions.create(
|
||
model=use_model,
|
||
messages=messages,
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
)
|
||
|
||
choice = response.choices[0]
|
||
content = choice.message.content or ""
|
||
usage = response.usage
|
||
|
||
# Try to parse as JSON anyway
|
||
parsed = self._extract_json(content)
|
||
|
||
citations = []
|
||
if hasattr(response, 'citations') and response.citations:
|
||
citations = response.citations
|
||
|
||
return PerplexityResponse(
|
||
content=content,
|
||
parsed_json=parsed,
|
||
prompt_tokens=usage.prompt_tokens if usage else 0,
|
||
completion_tokens=usage.completion_tokens if usage else 0,
|
||
model=use_model,
|
||
citations=citations,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error("[pplx] Plain chat failed: %s", e)
|
||
raise
|
||
|
||
async def achat_structured(self, *args, **kwargs) -> PerplexityResponse:
|
||
"""Async wrapper — runs structured chat in thread executor."""
|
||
loop = asyncio.get_event_loop()
|
||
return await loop.run_in_executor(
|
||
None, lambda: self.chat_structured(*args, **kwargs)
|
||
)
|
||
|
||
async def achat_plain(self, *args, **kwargs) -> PerplexityResponse:
|
||
"""Async wrapper — runs plain chat in thread executor."""
|
||
loop = asyncio.get_event_loop()
|
||
return await loop.run_in_executor(
|
||
None, lambda: self.chat_plain(*args, **kwargs)
|
||
)
|
||
|
||
@staticmethod
|
||
def _extract_json(text: str) -> Optional[dict]:
|
||
"""Best-effort JSON extraction from possibly noisy text."""
|
||
# Direct parse
|
||
try:
|
||
return json.loads(text.strip())
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
|
||
# Markdown fences
|
||
import re
|
||
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
|
||
if fence_match:
|
||
try:
|
||
return json.loads(fence_match.group(1).strip())
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
|
||
# Brace counting
|
||
start = text.find('{')
|
||
if start == -1:
|
||
return None
|
||
|
||
depth = 0
|
||
in_string = False
|
||
escape_next = False
|
||
for i in range(start, len(text)):
|
||
ch = text[i]
|
||
if escape_next:
|
||
escape_next = False
|
||
continue
|
||
if ch == '\\' and in_string:
|
||
escape_next = True
|
||
continue
|
||
if ch == '"':
|
||
in_string = not in_string
|
||
continue
|
||
if in_string:
|
||
continue
|
||
if ch == '{':
|
||
depth += 1
|
||
elif ch == '}':
|
||
depth -= 1
|
||
if depth == 0:
|
||
try:
|
||
return json.loads(text[start:i+1])
|
||
except (json.JSONDecodeError, TypeError):
|
||
return None
|
||
|
||
# If we get here, JSON was truncated (depth > 0). Try to repair.
|
||
return PerplexityClient._repair_truncated_json(text[start:])
|
||
|
||
@staticmethod
|
||
def _repair_truncated_json(text: str) -> Optional[dict]:
|
||
"""
|
||
Attempt to repair truncated JSON from max_tokens cutoff.
|
||
Strategy: find the last complete array element in "cards",
|
||
then close all open brackets/braces.
|
||
"""
|
||
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
|
||
|
||
# Find the last complete card object (ends with }) in the cards array
|
||
# Walk backwards to find the last closing brace that ends a valid card
|
||
last_good = -1
|
||
depth = 0
|
||
in_string = False
|
||
escape_next = False
|
||
|
||
for i in range(len(text)):
|
||
ch = text[i]
|
||
if escape_next:
|
||
escape_next = False
|
||
continue
|
||
if ch == '\\' and in_string:
|
||
escape_next = True
|
||
continue
|
||
if ch == '"':
|
||
in_string = not in_string
|
||
continue
|
||
if in_string:
|
||
continue
|
||
if ch == '{':
|
||
depth += 1
|
||
elif ch == '}':
|
||
depth -= 1
|
||
if depth >= 1:
|
||
# This closes a nested object (like a card) — mark as potential cutoff
|
||
last_good = i
|
||
|
||
if last_good == -1:
|
||
return None
|
||
|
||
# Take text up to the last complete nested object
|
||
truncated = text[:last_good + 1]
|
||
|
||
# Now figure out what we need to close.
|
||
# Re-scan to get the closing sequence.
|
||
close_depth = 0
|
||
in_arr = 0
|
||
in_str = False
|
||
esc = False
|
||
for ch in truncated:
|
||
if esc:
|
||
esc = False
|
||
continue
|
||
if ch == '\\' and in_str:
|
||
esc = True
|
||
continue
|
||
if ch == '"':
|
||
in_str = not in_str
|
||
continue
|
||
if in_str:
|
||
continue
|
||
if ch == '{':
|
||
close_depth += 1
|
||
elif ch == '}':
|
||
close_depth -= 1
|
||
elif ch == '[':
|
||
in_arr += 1
|
||
elif ch == ']':
|
||
in_arr -= 1
|
||
|
||
# Build closing sequence
|
||
suffix = ']' * in_arr + '}' * close_depth
|
||
repaired = truncated + suffix
|
||
|
||
try:
|
||
result = json.loads(repaired)
|
||
card_count = len(result.get('cards', []))
|
||
logger.info("JSON repair succeeded: recovered %d cards", card_count)
|
||
return result
|
||
except (json.JSONDecodeError, TypeError) as e:
|
||
logger.warning("JSON repair failed: %s", e)
|
||
return None
|
||
|
||
def check_status(self) -> dict:
|
||
"""Quick health check — try a minimal API call."""
|
||
try:
|
||
response = self._client.chat.completions.create(
|
||
model=self.model,
|
||
messages=[
|
||
{"role": "system", "content": "Reply with the word OK."},
|
||
{"role": "user", "content": "Status check."},
|
||
],
|
||
max_tokens=10,
|
||
)
|
||
return {
|
||
"connected": True,
|
||
"model": self.model,
|
||
"response": response.choices[0].message.content,
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"connected": False,
|
||
"model": self.model,
|
||
"error": str(e),
|
||
}
|