Arslan1997 commited on
Commit
528cb2d
·
1 Parent(s): 1e09b5f

launching

Browse files
.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 Postgres; no Redis)
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
- """One cheap call: is this worth researching, and what exactly should be researched?"""
 
 
 
 
 
 
 
 
 
 
 
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
- return ResearchResult(route.reply, False, [], route.prompt_tokens, route.completion_tokens,
 
 
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