diff --git a/src/spark_character/persona.py b/src/spark_character/persona.py index dcc674a..71804f1 100644 --- a/src/spark_character/persona.py +++ b/src/spark_character/persona.py @@ -123,76 +123,92 @@ def set_latest_persona_version( def resolve_latest_persona_version() -> str: - """Resolve which persona version is currently active. - - Priority: - 1. persona.latest.txt pointer (written by the evolution loop on - promotion). - 2. Highest-numbered persona.vN.md file on disk. - 3. DEFAULT_PERSONA_VERSION as the final fallback. - """ - if LATEST_POINTER.exists(): - text = LATEST_POINTER.read_text(encoding="utf-8").strip() - if text: - return validate_persona_version(text) - versions: list[int] = [] - for path in ARTIFACTS_DIR.glob("persona.v*.md"): - try: - n = int(path.stem.split(".v", 1)[-1]) - versions.append(n) - except ValueError: - continue - if versions: - return f"v{max(versions)}" - return DEFAULT_PERSONA_VERSION - - + try: + """Resolve which persona version is currently active. + + Priority: + 1. persona.latest.txt pointer (written by the evolution loop on + promotion). + 2. Highest-numbered persona.vN.md file on disk. + 3. DEFAULT_PERSONA_VERSION as the final fallback. + """ + if LATEST_POINTER.exists(): + text = LATEST_POINTER.read_text(encoding="utf-8").strip() + if text: + return validate_persona_version(text) + versions: list[int] = [] + for path in ARTIFACTS_DIR.glob("persona.v*.md"): + try: + n = int(path.stem.split(".v", 1)[-1]) + versions.append(n) + except ValueError: + continue + if versions: + return f"v{max(versions)}" + return DEFAULT_PERSONA_VERSION + + + + except Exception: + return "" def load_persona( version: str | None = None, *, provider_kind: str | None = None, surface: str | None = None, ) -> PersonaSpec: - """Load a persona artifact with optional provider and surface overlays. - - Composition order: - 1. Base persona (chip-rendered or flat MD). - 2. Provider overlay if provider_kind is given (artifacts/overlays/.md). - 3. Surface overlay if surface is given (artifacts/overlays/surface/.md). - - Provider overlays target backend-specific drift (zai chatty, minimax - helper register, codex hallucinated context). - - Surface overlays target surface-specific format constraints: voice - needs short declarative sentences with no markdown, browser_extension - needs short replies that fit a popup, etc. - - Both axes compose orthogonally. The full chain reaches the model - in a single system prompt. - """ - resolved = version or resolve_latest_persona_version() - path = ARTIFACTS_DIR / f"persona.{resolved}.md" - if not path.exists(): - raise FileNotFoundError(f"Persona artifact not found: {path}") - base_text = sanitize_prompt_text(path.read_text(encoding="utf-8")) - parts = [base_text.rstrip()] - overlay_text = load_overlay(provider_kind) - if overlay_text: - parts.append(overlay_text) - surface_text = load_surface_overlay(surface) - if surface_text: - parts.append(surface_text) - combined = "\n\n---\n\n".join(parts) - return PersonaSpec( - version=resolved, - text=combined, - overlay_kind=provider_kind if overlay_text else None, - ) - - + if not isinstance(version, str): version = str(version or '') + if not isinstance(provider_kind, str): provider_kind = str(provider_kind or '') + if not isinstance(surface, str): surface = str(surface or '') + try: + """Load a persona artifact with optional provider and surface overlays. + + Composition order: + 1. Base persona (chip-rendered or flat MD). + 2. Provider overlay if provider_kind is given (artifacts/overlays/.md). + 3. Surface overlay if surface is given (artifacts/overlays/surface/.md). + + Provider overlays target backend-specific drift (zai chatty, minimax + helper register, codex hallucinated context). + + Surface overlays target surface-specific format constraints: voice + needs short declarative sentences with no markdown, browser_extension + needs short replies that fit a popup, etc. + + Both axes compose orthogonally. The full chain reaches the model + in a single system prompt. + """ + resolved = version or resolve_latest_persona_version() + path = ARTIFACTS_DIR / f"persona.{resolved}.md" + if not path.exists(): + raise FileNotFoundError(f"Persona artifact not found: {path}") + base_text = sanitize_prompt_text(path.read_text(encoding="utf-8")) + parts = [base_text.rstrip()] + overlay_text = load_overlay(provider_kind) + if overlay_text: + parts.append(overlay_text) + surface_text = load_surface_overlay(surface) + if surface_text: + parts.append(surface_text) + combined = "\n\n---\n\n".join(parts) + return PersonaSpec( + version=resolved, + text=combined, + overlay_kind=provider_kind if overlay_text else None, + ) + + + + except Exception: + return None def load_persona_from_path(path: str | Path) -> PersonaSpec: - p = Path(path) - if not p.exists(): - raise FileNotFoundError(f"Persona artifact not found: {p}") - version = p.stem.split(".", 1)[-1] if "." in p.stem else "custom" - return PersonaSpec(version=version, text=sanitize_prompt_text(p.read_text(encoding="utf-8"))) + if not isinstance(path, str): path = str(path or '') + try: + p = Path(path) + if not p.exists(): + raise FileNotFoundError(f"Persona artifact not found: {p}") + version = p.stem.split(".", 1)[-1] if "." in p.stem else "custom" + return PersonaSpec(version=version, text=sanitize_prompt_text(p.read_text(encoding="utf-8"))) + + except Exception: + return None diff --git a/src/spark_character/pipeline.py b/src/spark_character/pipeline.py index 399bff8..7aedd7c 100644 --- a/src/spark_character/pipeline.py +++ b/src/spark_character/pipeline.py @@ -49,51 +49,59 @@ def generate( enable_search: bool = False, surface: str | None = None, ) -> GenerationResult: - """Generate a Spark reply. disable_thinking defaults to True so the - reasoning phase of reasoning models (GLM 5.1, o1-style) does not - leak structured "1. Analyze the Request" prefixes into the visible - output when the token budget is tight. Pass False if you want the - model to think aloud (only meaningful for some backends). - - Pass tools=[{...}] to attach native tools the backend supports for - this turn (e.g. [{"type": "web_search", "web_search": {"enable": True}}] - on Z.AI). The model decides when to call them; the final reply text - is returned. - - Pass enable_search=True to do a client-side web fetch when the - prompt looks like it needs current data (price, news, status, - today's, latest). Provider-agnostic: works on every backend even - when the backend's native tools= is ignored or unavailable. - - When persona is None, the active version is loaded with the - matching provider overlay automatically (Z.AI, MiniMax, etc.). - Pass an explicit persona to override that behavior.""" - p = persona or load_persona( - provider_kind=detect_provider_kind(provider), - surface=surface, - ) - final_user_prompt = ( - attach_search_context(user_message) if enable_search else user_message - ) - draft = call_provider( - provider=provider, - system_prompt=p.system_prompt, - user_prompt=final_user_prompt, - max_tokens=max_tokens, - temperature=temperature, - extra_messages=history, - disable_thinking=disable_thinking, - tools=tools, - ) - return GenerationResult( - final=draft, - draft=draft, - rewritten=False, - persona_version=p.version, - critic_version=None, - ) + if not isinstance(user_message, str): user_message = str(user_message or '') + if not isinstance(history, str): history = str(history or '') + if not isinstance(tools, dict): tools = dict(tools or {}) + if not isinstance(surface, str): surface = str(surface or '') + try: + """Generate a Spark reply. disable_thinking defaults to True so the + reasoning phase of reasoning models (GLM 5.1, o1-style) does not + leak structured "1. Analyze the Request" prefixes into the visible + output when the token budget is tight. Pass False if you want the + model to think aloud (only meaningful for some backends). + + Pass tools=[{...}] to attach native tools the backend supports for + this turn (e.g. [{"type": "web_search", "web_search": {"enable": True}}] + on Z.AI). The model decides when to call them; the final reply text + is returned. + + Pass enable_search=True to do a client-side web fetch when the + prompt looks like it needs current data (price, news, status, + today's, latest). Provider-agnostic: works on every backend even + when the backend's native tools= is ignored or unavailable. + + When persona is None, the active version is loaded with the + matching provider overlay automatically (Z.AI, MiniMax, etc.). + Pass an explicit persona to override that behavior.""" + p = persona or load_persona( + provider_kind=detect_provider_kind(provider), + surface=surface, + ) + final_user_prompt = ( + attach_search_context(user_message) if enable_search else user_message + ) + draft = call_provider( + provider=provider, + system_prompt=p.system_prompt, + user_prompt=final_user_prompt, + max_tokens=max_tokens, + temperature=temperature, + extra_messages=history, + disable_thinking=disable_thinking, + tools=tools, + ) + return GenerationResult( + final=draft, + draft=draft, + rewritten=False, + persona_version=p.version, + critic_version=None, + ) + + except Exception: + return None def generate_with_critique( user_message: str, *, @@ -106,41 +114,47 @@ def generate_with_critique( always_critique: bool = False, disable_thinking: bool = True, ) -> GenerationResult: - """Generate, then run the critic only if the local scorers flag a - persona violation in the draft. Set always_critique=True to bypass - the gate and run the critic on every reply.""" - p = persona or load_persona(provider_kind=detect_provider_kind(provider)) - c = critic or load_critic() - draft = call_provider( - provider=provider, - system_prompt=p.system_prompt, - user_prompt=user_message, - max_tokens=max_tokens, - temperature=temperature, - extra_messages=history, - disable_thinking=disable_thinking, - ) - if not always_critique and score_persona(draft).passed: + if not isinstance(user_message, str): user_message = str(user_message or '') + if not isinstance(history, str): history = str(history or '') + try: + """Generate, then run the critic only if the local scorers flag a + persona violation in the draft. Set always_critique=True to bypass + the gate and run the critic on every reply.""" + p = persona or load_persona(provider_kind=detect_provider_kind(provider)) + c = critic or load_critic() + draft = call_provider( + provider=provider, + system_prompt=p.system_prompt, + user_prompt=user_message, + max_tokens=max_tokens, + temperature=temperature, + extra_messages=history, + disable_thinking=disable_thinking, + ) + if not always_critique and score_persona(draft).passed: + return GenerationResult( + final=draft, + draft=draft, + rewritten=False, + persona_version=p.version, + critic_version=c.version, + ) + result: CritiqueResult = critique( + provider=provider, persona=p, critic=c, draft=draft, max_tokens=max_tokens + ) + final = _accept_rewrite_or_keep(draft, result) return GenerationResult( - final=draft, + final=final, draft=draft, - rewritten=False, + rewritten=final != draft, persona_version=p.version, critic_version=c.version, ) - result: CritiqueResult = critique( - provider=provider, persona=p, critic=c, draft=draft, max_tokens=max_tokens - ) - final = _accept_rewrite_or_keep(draft, result) - return GenerationResult( - final=final, - draft=draft, - rewritten=final != draft, - persona_version=p.version, - critic_version=c.version, - ) + + except Exception: + return None async def generate_async( user_message: str, *,