Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 82 additions & 66 deletions src/spark_character/persona.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<kind>.md).
3. Surface overlay if surface is given (artifacts/overlays/surface/<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/<kind>.md).
3. Surface overlay if surface is given (artifacts/overlays/surface/<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
156 changes: 85 additions & 71 deletions src/spark_character/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand All @@ -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,
*,
Expand Down