Spaces:
Runtime error
Runtime error
Commit ·
528cb2d
1
Parent(s): 1e09b5f
launching
Browse files- .env.example +27 -4
- backend/app/config.py +11 -0
- backend/app/pipeline/research.py +78 -2
- backend/app/services/jev.py +53 -0
- backend/tests/test_buildout.py +114 -0
- backend/tests/test_corpus.py +3 -1
.env.example
CHANGED
|
@@ -1,12 +1,18 @@
|
|
| 1 |
# ---- App
|
| 2 |
ENV=development
|
|
|
|
| 3 |
FRONTEND_URL=http://localhost:5173
|
| 4 |
API_URL=http://localhost:8000
|
| 5 |
CORS_ORIGINS=http://localhost:5173
|
| 6 |
-
JWT_SECRET=replace-with-64-random-chars
|
| 7 |
INTERNAL_TOKEN=replace-with-random-token
|
| 8 |
BILLING_ENABLED=false
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
# ---- Database (also backs the job queue)
|
| 11 |
# Local: a SQLite file, created by `alembic upgrade head` run from backend/.
|
| 12 |
# Prod (Neon, Supabase, RDS...): Postgres with the psycopg driver, e.g.
|
|
@@ -29,6 +35,8 @@ RESEND_API_KEY=
|
|
| 29 |
FROM_EMAIL=Notestack <hello@notestack.ai>
|
| 30 |
ALERTS_EMAIL=arslan@firebird-technologies.com
|
| 31 |
UNSUBSCRIBE_SECRET=replace-me
|
|
|
|
|
|
|
| 32 |
|
| 33 |
# ---- Local dev conveniences
|
| 34 |
# The API runs the job worker in process when ENV=development (set false to use python -m app.worker).
|
|
@@ -46,25 +54,37 @@ R2_SECRET_ACCESS_KEY=minioadmin
|
|
| 46 |
R2_BUCKET=notestack-assets
|
| 47 |
R2_ENDPOINT_URL=
|
| 48 |
R2_PUBLIC_BASE_URL=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
# ---- LLM (any LiteLLM model string). Defaults target Z.ai GLM-5.3 via its OpenAI compatible API.
|
| 51 |
# GLM-5.x always reasons; LLM_REASONING_EFFORT (low | high | max) trades cost for depth.
|
| 52 |
# Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
|
| 53 |
LLM_MODEL=openai/glm-5.3
|
| 54 |
LLM_FAST_MODEL=openai/glm-5.3-flash
|
| 55 |
-
# Chat triage classifier (greetings, off topic, follow ups). Empty = LLM_FAST_MODEL.
|
| 56 |
-
LLM_TRIAGE_MODEL=
|
| 57 |
LLM_API_BASE=https://api.z.ai/api/paas/v4
|
| 58 |
LLM_API_KEY=
|
| 59 |
LLM_TEMPERATURE=1.0
|
| 60 |
LLM_MAX_TOKENS=16000
|
| 61 |
LLM_REASONING_EFFORT=low
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
# ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
|
| 64 |
CORPUS_CACHE_DIR=.corpus-cache
|
| 65 |
RESEARCH_MAX_STEPS=10
|
| 66 |
|
| 67 |
-
# ---- Worker (jobs queue lives in
|
| 68 |
WORKER_CONCURRENCY=4
|
| 69 |
WORKER_POLL_SECONDS=1
|
| 70 |
JOB_STALE_SECONDS=900
|
|
@@ -75,6 +95,9 @@ ELEVENLABS_MODEL=eleven_multilingual_v2
|
|
| 75 |
ELEVENLABS_VOICE_A=EXAVITQu4vr4xnSDxMaL
|
| 76 |
ELEVENLABS_VOICE_B=JBFqnCBsd6RMkjVDRZzb
|
| 77 |
|
|
|
|
|
|
|
|
|
|
| 78 |
# ---- Launchpad social publishing
|
| 79 |
# Bluesky needs nothing here (users connect with an app password).
|
| 80 |
# X: developer app with OAuth 2.0, type Web App, scopes tweet.read tweet.write users.read offline.access.
|
|
|
|
| 1 |
# ---- App
|
| 2 |
ENV=development
|
| 3 |
+
APP_NAME=Notestack
|
| 4 |
FRONTEND_URL=http://localhost:5173
|
| 5 |
API_URL=http://localhost:8000
|
| 6 |
CORS_ORIGINS=http://localhost:5173
|
|
|
|
| 7 |
INTERNAL_TOKEN=replace-with-random-token
|
| 8 |
BILLING_ENABLED=false
|
| 9 |
|
| 10 |
+
# ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
|
| 11 |
+
JWT_SECRET=replace-with-64-random-chars
|
| 12 |
+
JWT_ALGORITHM=HS256
|
| 13 |
+
JWT_EXPIRATION_HOURS=72
|
| 14 |
+
JWT_REFRESH_EXPIRATION_DAYS=30
|
| 15 |
+
|
| 16 |
# ---- Database (also backs the job queue)
|
| 17 |
# Local: a SQLite file, created by `alembic upgrade head` run from backend/.
|
| 18 |
# Prod (Neon, Supabase, RDS...): Postgres with the psycopg driver, e.g.
|
|
|
|
| 35 |
FROM_EMAIL=Notestack <hello@notestack.ai>
|
| 36 |
ALERTS_EMAIL=arslan@firebird-technologies.com
|
| 37 |
UNSUBSCRIBE_SECRET=replace-me
|
| 38 |
+
# Hour (UTC, 0 to 23) at which scheduled update email campaigns send their daily batch.
|
| 39 |
+
UPDATE_EMAIL_SEND_HOUR=9
|
| 40 |
|
| 41 |
# ---- Local dev conveniences
|
| 42 |
# The API runs the job worker in process when ENV=development (set false to use python -m app.worker).
|
|
|
|
| 54 |
R2_BUCKET=notestack-assets
|
| 55 |
R2_ENDPOINT_URL=
|
| 56 |
R2_PUBLIC_BASE_URL=
|
| 57 |
+
# How long presigned download links stay valid, in seconds.
|
| 58 |
+
R2_PRESIGN_TTL_SECONDS=3600
|
| 59 |
+
# Largest file a browser may upload (bytes). 209715200 = 200 MB.
|
| 60 |
+
MAX_UPLOAD_BYTES=209715200
|
| 61 |
|
| 62 |
# ---- LLM (any LiteLLM model string). Defaults target Z.ai GLM-5.3 via its OpenAI compatible API.
|
| 63 |
# GLM-5.x always reasons; LLM_REASONING_EFFORT (low | high | max) trades cost for depth.
|
| 64 |
# Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
|
| 65 |
LLM_MODEL=openai/glm-5.3
|
| 66 |
LLM_FAST_MODEL=openai/glm-5.3-flash
|
|
|
|
|
|
|
| 67 |
LLM_API_BASE=https://api.z.ai/api/paas/v4
|
| 68 |
LLM_API_KEY=
|
| 69 |
LLM_TEMPERATURE=1.0
|
| 70 |
LLM_MAX_TOKENS=16000
|
| 71 |
LLM_REASONING_EFFORT=low
|
| 72 |
+
# Fallback chat triage model when Jev is not configured. Empty = LLM_FAST_MODEL.
|
| 73 |
+
LLM_TRIAGE_MODEL=
|
| 74 |
+
|
| 75 |
+
# ---- Chat triage: TypeSafe Jev (https://typesafe.ai)
|
| 76 |
+
# A fast classifier (typed choices, no text generation) that decides what each chat message needs
|
| 77 |
+
# before any research runs. With a key set, greetings, off topic and app questions never reach the
|
| 78 |
+
# LLM, and archive questions get a quick or deep research budget. Empty = LLM triage above.
|
| 79 |
+
TYPESAFE_API_KEY=
|
| 80 |
+
TYPESAFE_MODEL=jev-latest
|
| 81 |
+
TYPESAFE_URL=https://api.typesafe.ai
|
| 82 |
|
| 83 |
# ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
|
| 84 |
CORPUS_CACHE_DIR=.corpus-cache
|
| 85 |
RESEARCH_MAX_STEPS=10
|
| 86 |
|
| 87 |
+
# ---- Worker (jobs queue lives in the database; no Redis)
|
| 88 |
WORKER_CONCURRENCY=4
|
| 89 |
WORKER_POLL_SECONDS=1
|
| 90 |
JOB_STALE_SECONDS=900
|
|
|
|
| 95 |
ELEVENLABS_VOICE_A=EXAVITQu4vr4xnSDxMaL
|
| 96 |
ELEVENLABS_VOICE_B=JBFqnCBsd6RMkjVDRZzb
|
| 97 |
|
| 98 |
+
# ---- Renderer (Remotion service for videos, quote cards and carousels: cd renderer && npm start)
|
| 99 |
+
RENDERER_URL=http://localhost:3100
|
| 100 |
+
|
| 101 |
# ---- Launchpad social publishing
|
| 102 |
# Bluesky needs nothing here (users connect with an app password).
|
| 103 |
# X: developer app with OAuth 2.0, type Web App, scopes tweet.read tweet.write users.read offline.access.
|
backend/app/config.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from functools import lru_cache
|
| 2 |
from pathlib import Path
|
| 3 |
|
|
|
|
| 4 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
|
| 6 |
# One .env at the repo root serves every way of running the API; a backend/.env (or the
|
|
@@ -65,6 +66,10 @@ class Settings(BaseSettings):
|
|
| 65 |
llm_fast_model: str = "openai/glm-5.3-flash"
|
| 66 |
# Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL.
|
| 67 |
llm_triage_model: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
llm_api_base: str = "https://api.z.ai/api/paas/v4"
|
| 69 |
llm_api_key: str = ""
|
| 70 |
llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
|
|
@@ -97,6 +102,12 @@ class Settings(BaseSettings):
|
|
| 97 |
# Billing
|
| 98 |
billing_enabled: bool = False
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
@property
|
| 101 |
def worker_in_api(self) -> bool:
|
| 102 |
return self.env == "development" if self.run_worker_in_api is None else self.run_worker_in_api
|
|
|
|
| 1 |
from functools import lru_cache
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
+
from pydantic import field_validator
|
| 5 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
|
| 7 |
# One .env at the repo root serves every way of running the API; a backend/.env (or the
|
|
|
|
| 66 |
llm_fast_model: str = "openai/glm-5.3-flash"
|
| 67 |
# Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL.
|
| 68 |
llm_triage_model: str = ""
|
| 69 |
+
# TypeSafe Jev classifier for chat triage. When set, triage uses Jev instead of an LLM call.
|
| 70 |
+
typesafe_api_key: str = ""
|
| 71 |
+
typesafe_model: str = "jev-latest"
|
| 72 |
+
typesafe_url: str = "https://api.typesafe.ai"
|
| 73 |
llm_api_base: str = "https://api.z.ai/api/paas/v4"
|
| 74 |
llm_api_key: str = ""
|
| 75 |
llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
|
|
|
|
| 102 |
# Billing
|
| 103 |
billing_enabled: bool = False
|
| 104 |
|
| 105 |
+
@field_validator("run_worker_in_api", mode="before")
|
| 106 |
+
@classmethod
|
| 107 |
+
def _blank_is_default(cls, value):
|
| 108 |
+
"""RUN_WORKER_IN_API= (blank, as in .env.example) means "decide from ENV", not a parse error."""
|
| 109 |
+
return None if isinstance(value, str) and not value.strip() else value
|
| 110 |
+
|
| 111 |
@property
|
| 112 |
def worker_in_api(self) -> bool:
|
| 113 |
return self.env == "development" if self.run_worker_in_api is None else self.run_worker_in_api
|
backend/app/pipeline/research.py
CHANGED
|
@@ -7,6 +7,8 @@ conversation, answers off topic and app questions directly, and turns follow ups
|
|
| 7 |
questions. Only real archive questions run the research agent, with a small step budget for simple
|
| 8 |
lookups and the full budget for synthesis."""
|
| 9 |
|
|
|
|
|
|
|
| 10 |
import re
|
| 11 |
from collections.abc import Callable
|
| 12 |
from dataclasses import dataclass, field
|
|
@@ -18,8 +20,10 @@ from app.corpus import Corpus, ScopedTools
|
|
| 18 |
from app.llm.postprocess import strip_em_dashes
|
| 19 |
from app.llm.provider import main_lm, track_usage, triage_lm
|
| 20 |
from app.llm.signatures import ResearchArchive, TriageMessage
|
|
|
|
| 21 |
|
| 22 |
_MARKER = re.compile(r"\[(\d+)\]")
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
@dataclass
|
|
@@ -98,8 +102,78 @@ class Triage:
|
|
| 98 |
completion_tokens: int = 0
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
def triage(message: str, history: list[Turn], notebook: str) -> Triage:
|
| 102 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
lm = triage_lm()
|
| 104 |
try:
|
| 105 |
with track_usage(lm) as usage, dspy.context(lm=lm):
|
|
@@ -175,7 +249,9 @@ def research(
|
|
| 175 |
on_step("think", "Reading the conversation")
|
| 176 |
route = triage(question, history, notebook_brief(notebook_title, allowed))
|
| 177 |
if route.kind != "archive":
|
| 178 |
-
|
|
|
|
|
|
|
| 179 |
kind=route.kind, question=question)
|
| 180 |
|
| 181 |
corpus.sync()
|
|
|
|
| 7 |
questions. Only real archive questions run the research agent, with a small step budget for simple
|
| 8 |
lookups and the full budget for synthesis."""
|
| 9 |
|
| 10 |
+
import logging
|
| 11 |
+
import random
|
| 12 |
import re
|
| 13 |
from collections.abc import Callable
|
| 14 |
from dataclasses import dataclass, field
|
|
|
|
| 20 |
from app.llm.postprocess import strip_em_dashes
|
| 21 |
from app.llm.provider import main_lm, track_usage, triage_lm
|
| 22 |
from app.llm.signatures import ResearchArchive, TriageMessage
|
| 23 |
+
from app.services import jev
|
| 24 |
|
| 25 |
_MARKER = re.compile(r"\[(\d+)\]")
|
| 26 |
+
log = logging.getLogger(__name__)
|
| 27 |
|
| 28 |
|
| 29 |
@dataclass
|
|
|
|
| 102 |
completion_tokens: int = 0
|
| 103 |
|
| 104 |
|
| 105 |
+
JEV_INTENT = {
|
| 106 |
+
"archive": {
|
| 107 |
+
"what": "Needs facts, ideas, quotes, opinions or history from the writer's own posts, including follow "
|
| 108 |
+
"ups to the conversation like 'say more', 'why?', 'what about last year?'",
|
| 109 |
+
"examples": ["What have I written about pricing?", "Summarize my take on AI", "Tell me more about that"],
|
| 110 |
+
},
|
| 111 |
+
"chitchat": {
|
| 112 |
+
"what": "Greetings, thanks, reactions or small talk with no question about the posts",
|
| 113 |
+
"examples": ["hello there", "that was helpful, thanks", "you are great"],
|
| 114 |
+
},
|
| 115 |
+
"about_app": {
|
| 116 |
+
"what": "How to use Notestack itself: sources, notebooks, audio, video, launch kits, settings",
|
| 117 |
+
"examples": ["How do I add another Substack?", "Can you make a podcast from this?"],
|
| 118 |
+
},
|
| 119 |
+
"off_topic": {
|
| 120 |
+
"what": "General knowledge, coding, math, news, homework or tasks unrelated to the writer's posts",
|
| 121 |
+
"not_for": "Questions about what the writer wrote, even if the subject is general",
|
| 122 |
+
"examples": ["What is the capital of France?", "Write me a Python script", "Who won the game last night?"],
|
| 123 |
+
},
|
| 124 |
+
}
|
| 125 |
+
JEV_DEPTH = {
|
| 126 |
+
"quick": "A single fact, quote or lookup in one or two posts",
|
| 127 |
+
"deep": "Synthesis, comparison or change over time across many posts",
|
| 128 |
+
}
|
| 129 |
+
JEV_MIN_CONFIDENCE = 0.55 # below this, research anyway: a wasted search beats a wrong brush off
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def canned_reply(kind: str, allowed: dict[str, str]) -> str:
|
| 133 |
+
"""Templated replies for messages that need no research (no model call)."""
|
| 134 |
+
titles = [t for t in allowed.values() if t]
|
| 135 |
+
sample = random.choice(titles) if titles else None
|
| 136 |
+
try_this = f' For example: "What is the main argument in {sample}?"' if sample else ""
|
| 137 |
+
if kind == "about_app":
|
| 138 |
+
return ("Add more Substacks, blogs or files on the Sources page, then group posts into notebooks. From "
|
| 139 |
+
"any notebook, the Studio panel on the right makes summaries, audio overviews, videos and quote "
|
| 140 |
+
"cards, and Launch Kit and Launchpad turn a post into scheduled social posts. In this chat I "
|
| 141 |
+
"answer questions about the posts in this notebook." + try_this)
|
| 142 |
+
if kind == "off_topic":
|
| 143 |
+
return ("I only answer from the posts in this notebook, so I cannot help with that one. Ask me about "
|
| 144 |
+
"something you have written instead." + try_this)
|
| 145 |
+
return "Happy to help. Ask me anything about the posts in this notebook." + try_this
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def triage_with_jev(message: str, history: list[Turn], notebook: str) -> Triage:
|
| 149 |
+
state = {"notebook": notebook, "conversation": conversation_text(history, limit=4), "message": message}
|
| 150 |
+
answers = jev.decide(state, {
|
| 151 |
+
"intent": {"instructions": "What does the latest message need from a research assistant that only "
|
| 152 |
+
"knows this writer's posts?", "criteria": JEV_INTENT},
|
| 153 |
+
"depth": {"instructions": "If it is about the posts, how much research does it need?",
|
| 154 |
+
"criteria": JEV_DEPTH},
|
| 155 |
+
})
|
| 156 |
+
intent, depth = answers["intent"], answers["depth"]
|
| 157 |
+
kind = intent.choice if intent.choice in JEV_INTENT else "archive"
|
| 158 |
+
if kind != "archive" and intent.confidence < JEV_MIN_CONFIDENCE:
|
| 159 |
+
kind = "archive"
|
| 160 |
+
# Jev does not write text: the research agent resolves follow ups using the conversation it is given.
|
| 161 |
+
return Triage(kind, message, depth.choice if depth.choice in JEV_DEPTH else "deep", "")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
def triage(message: str, history: list[Turn], notebook: str) -> Triage:
|
| 165 |
+
"""Decide whether a message is worth researching. Jev when configured (fast, typed, no generation),
|
| 166 |
+
otherwise one cheap call on the triage LLM."""
|
| 167 |
+
if jev.configured():
|
| 168 |
+
try:
|
| 169 |
+
return triage_with_jev(message, history, notebook)
|
| 170 |
+
except jev.JevError:
|
| 171 |
+
log.warning("Jev triage failed, falling back to the LLM", exc_info=True)
|
| 172 |
+
return triage_llm(message, history, notebook)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def triage_llm(message: str, history: list[Turn], notebook: str) -> Triage:
|
| 176 |
+
"""One cheap LLM call: is this worth researching, and what exactly should be researched?"""
|
| 177 |
lm = triage_lm()
|
| 178 |
try:
|
| 179 |
with track_usage(lm) as usage, dspy.context(lm=lm):
|
|
|
|
| 249 |
on_step("think", "Reading the conversation")
|
| 250 |
route = triage(question, history, notebook_brief(notebook_title, allowed))
|
| 251 |
if route.kind != "archive":
|
| 252 |
+
# App help is always the vetted template: a model would invent product facts.
|
| 253 |
+
reply = canned_reply(route.kind, allowed) if route.kind == "about_app" or not route.reply else route.reply
|
| 254 |
+
return ResearchResult(reply, False, [], route.prompt_tokens, route.completion_tokens,
|
| 255 |
kind=route.kind, question=question)
|
| 256 |
|
| 257 |
corpus.sync()
|
backend/app/services/jev.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TypeSafe Jev: a System One classifier. It picks from options we define (with probabilities) and never
|
| 2 |
+
generates text, so it is the fast, cheap way to decide what a chat message needs.
|
| 3 |
+
|
| 4 |
+
API: POST {TYPESAFE_URL}/v1/systemone with {"state", "model", "questions"}; answers come back per
|
| 5 |
+
question id with "choice", "confidence" and "probabilities". https://docs.typesafe.ai/primitives/choice
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
from app.config import settings
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class JevError(RuntimeError):
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class Choice:
|
| 21 |
+
choice: str
|
| 22 |
+
confidence: float
|
| 23 |
+
probabilities: dict[str, float]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def configured() -> bool:
|
| 27 |
+
return bool(settings.typesafe_api_key)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def decide(state: str | dict, questions: dict[str, dict], timeout: float = 6.0) -> dict[str, Choice]:
|
| 31 |
+
"""Ask several Choice questions about one state in a single request (Jev evaluates them in parallel)."""
|
| 32 |
+
if not configured():
|
| 33 |
+
raise JevError("TYPESAFE_API_KEY is not set")
|
| 34 |
+
body = {
|
| 35 |
+
"state": state,
|
| 36 |
+
"model": settings.typesafe_model,
|
| 37 |
+
"questions": {qid: {"type": "choice", **q} for qid, q in questions.items()},
|
| 38 |
+
}
|
| 39 |
+
try:
|
| 40 |
+
resp = httpx.post(f"{settings.typesafe_url.rstrip('/')}/v1/systemone", json=body, timeout=timeout,
|
| 41 |
+
headers={"Authorization": f"Bearer {settings.typesafe_api_key}"})
|
| 42 |
+
except httpx.HTTPError as exc:
|
| 43 |
+
raise JevError(f"Jev unreachable: {exc}") from exc
|
| 44 |
+
if resp.status_code >= 400:
|
| 45 |
+
raise JevError(f"Jev {resp.status_code}: {resp.text[:300]}")
|
| 46 |
+
answers = resp.json().get("answers") or {}
|
| 47 |
+
out = {}
|
| 48 |
+
for qid in questions:
|
| 49 |
+
a = answers.get(qid) or {}
|
| 50 |
+
if "choice" not in a:
|
| 51 |
+
raise JevError(f"Jev returned no answer for {qid}")
|
| 52 |
+
out[qid] = Choice(a["choice"], float(a.get("confidence") or 0), a.get("probabilities") or {})
|
| 53 |
+
return out
|
backend/tests/test_buildout.py
CHANGED
|
@@ -708,3 +708,117 @@ def test_tts_retries_rate_limits(monkeypatch):
|
|
| 708 |
monkeypatch.setattr(httpx, "request", lambda *a, **k: httpx.Response(401, json={"detail": {"message": "bad"}}))
|
| 709 |
with pytest.raises(tts.TTSError, match="API key"):
|
| 710 |
tts._request("GET", "/voices")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
monkeypatch.setattr(httpx, "request", lambda *a, **k: httpx.Response(401, json={"detail": {"message": "bad"}}))
|
| 709 |
with pytest.raises(tts.TTSError, match="API key"):
|
| 710 |
tts._request("GET", "/voices")
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
# Chat: memory and triage (no LLM calls for chatter)
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def _no_research(monkeypatch):
|
| 717 |
+
import dspy
|
| 718 |
+
|
| 719 |
+
def boom(*a, **k):
|
| 720 |
+
raise AssertionError("research agent should not run")
|
| 721 |
+
|
| 722 |
+
monkeypatch.setattr(dspy, "ReAct", boom)
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def test_small_talk_needs_no_model(monkeypatch, tmp_path):
|
| 726 |
+
from app.corpus import Corpus
|
| 727 |
+
from app.pipeline import research as r
|
| 728 |
+
|
| 729 |
+
_no_research(monkeypatch)
|
| 730 |
+
monkeypatch.setattr(r, "triage", lambda *a: (_ for _ in ()).throw(AssertionError("no triage for chatter")))
|
| 731 |
+
out = r.research(Corpus(uuid.uuid4(), cache_dir=str(tmp_path)), {"a.md": "On Pricing"}, "thanks!",
|
| 732 |
+
history=[r.Turn("user", "q"), r.Turn("assistant", "a")])
|
| 733 |
+
assert out.kind == "chitchat" and "helped" in out.text and out.prompt_tokens == 0
|
| 734 |
+
|
| 735 |
+
|
| 736 |
+
def test_jev_triage_routes_off_topic_without_llm(monkeypatch, tmp_path):
|
| 737 |
+
from app.config import settings
|
| 738 |
+
from app.corpus import Corpus
|
| 739 |
+
from app.pipeline import research as r
|
| 740 |
+
from app.services import jev
|
| 741 |
+
|
| 742 |
+
_no_research(monkeypatch)
|
| 743 |
+
monkeypatch.setattr(settings, "typesafe_api_key", "sk-test")
|
| 744 |
+
seen = {}
|
| 745 |
+
|
| 746 |
+
def decide(state, questions, timeout=6.0):
|
| 747 |
+
seen.update(state=state, questions=questions)
|
| 748 |
+
return {"intent": jev.Choice("off_topic", 0.93, {}), "depth": jev.Choice("quick", 0.8, {})}
|
| 749 |
+
|
| 750 |
+
monkeypatch.setattr(jev, "decide", decide)
|
| 751 |
+
monkeypatch.setattr(r, "triage_llm", lambda *a: (_ for _ in ()).throw(AssertionError("LLM triage not needed")))
|
| 752 |
+
out = r.research(Corpus(uuid.uuid4(), cache_dir=str(tmp_path)), {"a.md": "On Pricing"},
|
| 753 |
+
"Write me a python script", history=[r.Turn("user", "What did I say about pricing?")])
|
| 754 |
+
assert out.kind == "off_topic" and "On Pricing" in out.text
|
| 755 |
+
assert seen["questions"]["intent"]["criteria"]["archive"]
|
| 756 |
+
assert "pricing" in seen["state"]["conversation"]
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def test_jev_low_confidence_or_failure_still_researches(monkeypatch, tmp_path):
|
| 760 |
+
from app.config import settings
|
| 761 |
+
from app.pipeline import research as r
|
| 762 |
+
from app.services import jev
|
| 763 |
+
|
| 764 |
+
monkeypatch.setattr(settings, "typesafe_api_key", "sk-test")
|
| 765 |
+
monkeypatch.setattr(jev, "decide", lambda *a, **k: {"intent": jev.Choice("off_topic", 0.4, {}),
|
| 766 |
+
"depth": jev.Choice("deep", 0.9, {})})
|
| 767 |
+
assert r.triage("hmm what about that", [], "nb").kind == "archive"
|
| 768 |
+
|
| 769 |
+
def down(*a, **k):
|
| 770 |
+
raise jev.JevError("down")
|
| 771 |
+
|
| 772 |
+
monkeypatch.setattr(jev, "decide", down)
|
| 773 |
+
monkeypatch.setattr(r, "triage_llm", lambda *a: r.Triage("archive", "standalone q", "quick", ""))
|
| 774 |
+
t = r.triage("tell me more", [], "nb")
|
| 775 |
+
assert t.question == "standalone q" and t.depth == "quick"
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
def test_jev_request_shape(monkeypatch):
|
| 779 |
+
from app.config import settings
|
| 780 |
+
from app.services import jev
|
| 781 |
+
|
| 782 |
+
monkeypatch.setattr(settings, "typesafe_api_key", "sk-test")
|
| 783 |
+
captured = {}
|
| 784 |
+
|
| 785 |
+
def post(url, json, timeout, headers):
|
| 786 |
+
captured.update(url=url, body=json, headers=headers)
|
| 787 |
+
return httpx.Response(200, json={"answers": {"intent": {"type": "choice", "choice": "archive",
|
| 788 |
+
"confidence": 0.9,
|
| 789 |
+
"probabilities": {"archive": 0.9}}}})
|
| 790 |
+
|
| 791 |
+
monkeypatch.setattr(httpx, "post", post)
|
| 792 |
+
out = jev.decide("state", {"intent": {"instructions": "?", "criteria": {"archive": "x"}}})
|
| 793 |
+
assert out["intent"].choice == "archive"
|
| 794 |
+
assert captured["url"].endswith("/v1/systemone") and captured["headers"]["Authorization"] == "Bearer sk-test"
|
| 795 |
+
assert captured["body"]["questions"]["intent"]["type"] == "choice" and captured["body"]["model"] == "jev-latest"
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
def test_chat_passes_history_as_memory(client, auth, run_jobs, feed, monkeypatch):
|
| 799 |
+
from app.pipeline.research import ResearchResult
|
| 800 |
+
from app.routers import notebooks as nbr
|
| 801 |
+
|
| 802 |
+
connect(client, auth, run_jobs)
|
| 803 |
+
doc = docs(client, auth)[0]
|
| 804 |
+
nb = client.post("/api/notebooks", json={"title": "N", "document_ids": [doc["id"]]}, headers=auth).json()
|
| 805 |
+
calls = []
|
| 806 |
+
|
| 807 |
+
def fake_research(corpus, allowed, question, on_step=None, history=None, notebook_title=""):
|
| 808 |
+
calls.append((question, [(t.role, t.text) for t in history or []], notebook_title))
|
| 809 |
+
return ResearchResult(f"answer to {question}", False, [])
|
| 810 |
+
|
| 811 |
+
monkeypatch.setattr(nbr, "research", fake_research)
|
| 812 |
+
|
| 813 |
+
def ask(q, chat_id=None):
|
| 814 |
+
body = client.post(f"/api/notebooks/{nb['id']}/chat", json={"question": q, "chat_id": chat_id},
|
| 815 |
+
headers=auth).text
|
| 816 |
+
import json as _json
|
| 817 |
+
import re as _re
|
| 818 |
+
return _json.loads(_re.search(r"event: status\ndata: (.*)", body).group(1))["chat_id"]
|
| 819 |
+
|
| 820 |
+
chat_id = ask("What did I say about pricing?")
|
| 821 |
+
ask("Why?", chat_id)
|
| 822 |
+
assert calls[0][1] == [] and calls[0][2] == "N"
|
| 823 |
+
assert calls[1][1] == [("user", "What did I say about pricing?"),
|
| 824 |
+
("assistant", "answer to What did I say about pricing?")]
|
backend/tests/test_corpus.py
CHANGED
|
@@ -125,12 +125,14 @@ def test_react_agent_end_to_end_with_scripted_model(corpus_pair, monkeypatch):
|
|
| 125 |
])
|
| 126 |
monkeypatch.setattr(research_mod, "main_lm", lambda: lm)
|
| 127 |
monkeypatch.setattr(research_mod, "track_usage", _no_usage)
|
|
|
|
|
|
|
| 128 |
seen = []
|
| 129 |
with dspy.context(adapter=dspy.ChatAdapter()):
|
| 130 |
result = research_mod.research(
|
| 131 |
reader, {p1: "On pricing"}, "What did I charge?", on_step=lambda k, d: seen.append(k)
|
| 132 |
)
|
| 133 |
-
assert seen == ["search", "read"]
|
| 134 |
assert result.unsupported is False
|
| 135 |
assert result.text.endswith("[1].")
|
| 136 |
assert result.citations[0].path == p1 and "$5 to $8" in result.citations[0].quote
|
|
|
|
| 125 |
])
|
| 126 |
monkeypatch.setattr(research_mod, "main_lm", lambda: lm)
|
| 127 |
monkeypatch.setattr(research_mod, "track_usage", _no_usage)
|
| 128 |
+
monkeypatch.setattr(research_mod, "triage", lambda *a: research_mod.Triage("archive", "What did I charge?",
|
| 129 |
+
"deep", ""))
|
| 130 |
seen = []
|
| 131 |
with dspy.context(adapter=dspy.ChatAdapter()):
|
| 132 |
result = research_mod.research(
|
| 133 |
reader, {p1: "On pricing"}, "What did I charge?", on_step=lambda k, d: seen.append(k)
|
| 134 |
)
|
| 135 |
+
assert seen == ["think", "search", "read"]
|
| 136 |
assert result.unsupported is False
|
| 137 |
assert result.text.endswith("[1].")
|
| 138 |
assert result.citations[0].path == p1 and "$5 to $8" in result.citations[0].quote
|