HumeraR commited on
Commit
6e43a65
·
1 Parent(s): b74c6ae

refactor: improve code readability and organization across multiple files

Browse files
backend/app/chat_memory/organize.py CHANGED
@@ -37,7 +37,7 @@ FALLBACK_TOPIC = "General chat"
37
  # A new topic whose words overlap an existing topic's this much is the same topic, so its rounds join that one.
38
  MERGE_OVERLAP = 0.5 # shared words as a share of the smaller of the two word sets
39
  MERGE_MIN_SHARED = 4 # and at least this many shared words, so two short lists cannot match by luck
40
- REFUSAL_OPENING = "I only answer from the posts in this notebook" # the canned off-topic reply, for rows saved before kinds
41
 
42
 
43
  def chat_corpus(workspace_id: uuid.UUID, notebook_id: uuid.UUID, store=None, cache_dir: str | None = None) -> Corpus:
 
37
  # A new topic whose words overlap an existing topic's this much is the same topic, so its rounds join that one.
38
  MERGE_OVERLAP = 0.5 # shared words as a share of the smaller of the two word sets
39
  MERGE_MIN_SHARED = 4 # and at least this many shared words, so two short lists cannot match by luck
40
+ REFUSAL_OPENING = "I only answer from the posts in this notebook" # canned off-topic reply, rows saved before kinds
41
 
42
 
43
  def chat_corpus(workspace_id: uuid.UUID, notebook_id: uuid.UUID, store=None, cache_dir: str | None = None) -> Corpus:
backend/app/pipeline/generate.py CHANGED
@@ -10,7 +10,15 @@ from sqlalchemy.orm import Session
10
  from app.corpus import Corpus, slugify
11
  from app.llm import run
12
  from app.llm.provider import fast_lm
13
- from app.llm.signatures import ArrangeMindMap, BuildVoiceProfile, ConsolidateTopics, EvergreenScore, ExtractIdeas, ExtractTopics, SummarizeNotebook
 
 
 
 
 
 
 
 
14
  from app.models import Artifact, DocumentTopic, Job, Notebook, Topic, VoiceProfile
15
  from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
16
  from app.pipeline.research import verify_citations
@@ -118,7 +126,7 @@ def extract_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, doc_ids
118
  continue
119
  tools = tools_for(corpus, [d])
120
 
121
- def keep(items) -> list[dict]:
122
  return [{"path": r["path"], "line_start": r["line_start"], "line_end": r["line_end"]}
123
  for r in verify_refs(tools, items or []) if r["path"] == d.path]
124
 
@@ -224,8 +232,8 @@ def build_mind_map(db: Session, job: Job, artifact: Artifact) -> dict:
224
  return 1 + sum(count(c) for c in n["children"])
225
 
226
  artifact.content_json = {
227
- "title": f"Mind Constellation: {focus[:60] or nb.title}", "focus": focus, "root": root, "node_count": count(root),
228
- "document_ids": [str(d.id) for d in docs], "post_count": len(docs),
229
  "covered_count": len(_cited(branches) & {str(d.id) for d in docs}),
230
  }
231
  artifact.status = "ready"
 
10
  from app.corpus import Corpus, slugify
11
  from app.llm import run
12
  from app.llm.provider import fast_lm
13
+ from app.llm.signatures import (
14
+ ArrangeMindMap,
15
+ BuildVoiceProfile,
16
+ ConsolidateTopics,
17
+ EvergreenScore,
18
+ ExtractIdeas,
19
+ ExtractTopics,
20
+ SummarizeNotebook,
21
+ )
22
  from app.models import Artifact, DocumentTopic, Job, Notebook, Topic, VoiceProfile
23
  from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
24
  from app.pipeline.research import verify_citations
 
126
  continue
127
  tools = tools_for(corpus, [d])
128
 
129
+ def keep(items, tools=tools, d=d) -> list[dict]:
130
  return [{"path": r["path"], "line_start": r["line_start"], "line_end": r["line_end"]}
131
  for r in verify_refs(tools, items or []) if r["path"] == d.path]
132
 
 
232
  return 1 + sum(count(c) for c in n["children"])
233
 
234
  artifact.content_json = {
235
+ "title": f"Mind Constellation: {focus[:60] or nb.title}", "focus": focus, "root": root,
236
+ "node_count": count(root), "document_ids": [str(d.id) for d in docs], "post_count": len(docs),
237
  "covered_count": len(_cited(branches) & {str(d.id) for d in docs}),
238
  }
239
  artifact.status = "ready"
backend/app/pipeline/research.py CHANGED
@@ -67,7 +67,7 @@ class ResearchResult:
67
  steps: list[tuple[str, str]] = field(default_factory=list)
68
  kind: str = "archive" # archive | chitchat | about_app | off_topic
69
  question: str = "" # the standalone question that was researched
70
- recall: dict = field(default_factory=dict) # how recall behaved for this answer (saved with the message, for feedback)
71
 
72
 
73
  @dataclass
 
67
  steps: list[tuple[str, str]] = field(default_factory=list)
68
  kind: str = "archive" # archive | chitchat | about_app | off_topic
69
  question: str = "" # the standalone question that was researched
70
+ recall: dict = field(default_factory=dict) # how recall behaved for this answer (saved with the message)
71
 
72
 
73
  @dataclass
backend/app/routers/notebooks.py CHANGED
@@ -470,7 +470,7 @@ async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)
470
  try:
471
  result = task.result()
472
  except Exception:
473
- log.exception("Research failed for chat %s: %r", chat_row.id, body.question[:200]) # the cause, not just "lost signal"
474
  trace.error = "research raised (see the traceback above); no answer was saved"
475
  yield _sse("error", {"message": "Lost signal while researching. Try again."})
476
  return
 
470
  try:
471
  result = task.result()
472
  except Exception:
473
+ log.exception("Research failed for chat %s: %r", chat_row.id, body.question[:200]) # cause, not just "lost signal"
474
  trace.error = "research raised (see the traceback above); no answer was saved"
475
  yield _sse("error", {"message": "Lost signal while researching. Try again."})
476
  return
backend/app/routers/support.py CHANGED
@@ -59,7 +59,8 @@ def _rate_limit(user_id: uuid.UUID) -> None:
59
  while window and now - window[0] > 60:
60
  window.popleft()
61
  if len(window) >= settings.support_messages_per_minute:
62
- raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "You are sending messages quickly. Try again in a minute.")
 
63
  window.append(now)
64
 
65
 
@@ -73,7 +74,8 @@ def _excerpt(body: str, query: str, budget: int = DOC_BUDGET_CHARS) -> str:
73
  return body
74
  words = set(re.findall(r"[a-z0-9]+", query.lower()))
75
  sections = re.split(r"(?m)^(?=#{1,3}\s)", body)
76
- ranked = sorted(range(len(sections)), key=lambda i: -len(words & set(re.findall(r"[a-z0-9]+", sections[i].lower()))))
 
77
  keep, used = set(), 0
78
  for i in ranked:
79
  if used + len(sections[i]) > budget and keep:
@@ -144,13 +146,15 @@ async def chat_stream(body: ChatIn, ctx: Ctx = Depends(get_ctx)) -> StreamingRes
144
  drafted = ""
145
  try: # buffered, not streamed: the line is checked before the writer sees it
146
  async for tok in llm.stream_answer(
147
- [{"role": "system", "content": esc.handoff_prompt(question_reason)}, {"role": "user", "content": message}]
 
148
  ):
149
  drafted += tok
150
  except LLMError:
151
  drafted = ""
152
  drafted = strip_em_dashes(drafted.strip())
153
- answer = drafted if drafted and esc.handoff_line_is_safe(drafted) else esc.short_circuit_reply(question_reason, len(recent))
 
154
  escalate_reason = question_reason
155
  yield _sse("token", answer)
156
  else:
@@ -202,7 +206,8 @@ async def chat_stream(body: ChatIn, ctx: Ctx = Depends(get_ctx)) -> StreamingRes
202
  try:
203
  conv.summary = strip_em_dashes(await llm.complete_text(
204
  [{"role": "system", "content": prompts.SUMMARY_PROMPT},
205
- {"role": "user", "content": f"Previous summary: {conv.summary or '(none)'}\n\nNew turns:\n{transcript}"}],
 
206
  max_tokens=500))[:1200]
207
  db.commit()
208
  except LLMError:
@@ -221,7 +226,8 @@ def latest(ctx: Ctx = Depends(get_ctx)):
221
  if not conv:
222
  return {"conversation_id": None, "messages": []}
223
  msgs = ctx.db.scalars(
224
- select(SupportMessage).where(SupportMessage.conversation_id == conv.id).order_by(SupportMessage.created_at).limit(60)
 
225
  ).all()
226
  return {"conversation_id": str(conv.id), "messages": [_message_out(m) for m in msgs]}
227
 
 
59
  while window and now - window[0] > 60:
60
  window.popleft()
61
  if len(window) >= settings.support_messages_per_minute:
62
+ raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS,
63
+ "You are sending messages quickly. Try again in a minute.")
64
  window.append(now)
65
 
66
 
 
74
  return body
75
  words = set(re.findall(r"[a-z0-9]+", query.lower()))
76
  sections = re.split(r"(?m)^(?=#{1,3}\s)", body)
77
+ ranked = sorted(range(len(sections)),
78
+ key=lambda i: -len(words & set(re.findall(r"[a-z0-9]+", sections[i].lower()))))
79
  keep, used = set(), 0
80
  for i in ranked:
81
  if used + len(sections[i]) > budget and keep:
 
146
  drafted = ""
147
  try: # buffered, not streamed: the line is checked before the writer sees it
148
  async for tok in llm.stream_answer(
149
+ [{"role": "system", "content": esc.handoff_prompt(question_reason)},
150
+ {"role": "user", "content": message}]
151
  ):
152
  drafted += tok
153
  except LLMError:
154
  drafted = ""
155
  drafted = strip_em_dashes(drafted.strip())
156
+ answer = (drafted if drafted and esc.handoff_line_is_safe(drafted)
157
+ else esc.short_circuit_reply(question_reason, len(recent)))
158
  escalate_reason = question_reason
159
  yield _sse("token", answer)
160
  else:
 
206
  try:
207
  conv.summary = strip_em_dashes(await llm.complete_text(
208
  [{"role": "system", "content": prompts.SUMMARY_PROMPT},
209
+ {"role": "user", "content": f"Previous summary: {conv.summary or '(none)'}\n\n"
210
+ f"New turns:\n{transcript}"}],
211
  max_tokens=500))[:1200]
212
  db.commit()
213
  except LLMError:
 
226
  if not conv:
227
  return {"conversation_id": None, "messages": []}
228
  msgs = ctx.db.scalars(
229
+ select(SupportMessage).where(SupportMessage.conversation_id == conv.id)
230
+ .order_by(SupportMessage.created_at).limit(60)
231
  ).all()
232
  return {"conversation_id": str(conv.id), "messages": [_message_out(m) for m in msgs]}
233
 
backend/app/support/corpus.py CHANGED
@@ -74,22 +74,24 @@ def _plans_doc() -> Doc:
74
  lines += [
75
  f"## {p.name}",
76
  f"{p.tagline}. Price: {price}.",
77
- f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts, {p.audio_minutes} minutes of audio a month, "
 
78
  f"{p.video_minutes} minutes of video a month, {kits} Launch Kits a month. "
79
  f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.",
80
  "Includes: " + "; ".join(p.features) + ".",
81
  ]
82
  lines += [
83
  "## Billing",
84
- "Upgrade, change plan or manage billing from Settings under Plan and usage. Annual billing is cheaper than monthly. "
85
- "Checkout may be switched off while billing is being finished, in which case every workspace gets Studio limits.",
 
86
  "Refunds and billing problems are handled by the team: use the Talk to a human button in this chat.",
87
  ]
88
  body = "\n".join(lines)
89
  return Doc(
90
  id="plans-and-billing", title="Plans, pricing and billing", route="/pricing",
91
- keywords=("price", "pricing", "plan", "plans", "free", "writer", "studio", "cost", "billing", "upgrade", "limits",
92
- "annual", "monthly", "subscription", "refund", "cancel", "usage"),
93
  questions=("How much does Notestack cost?", "What is in the free plan?", "What are the plan limits?",
94
  "How do I upgrade?", "Is there annual billing?"),
95
  related_paths=("/app/settings",), headings=tuple(re.findall(r"^#{1,3}\s+(.+)$", body, re.M)), body=body,
 
74
  lines += [
75
  f"## {p.name}",
76
  f"{p.tagline}. Price: {price}.",
77
+ f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts, "
78
+ f"{p.audio_minutes} minutes of audio a month, "
79
  f"{p.video_minutes} minutes of video a month, {kits} Launch Kits a month. "
80
  f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.",
81
  "Includes: " + "; ".join(p.features) + ".",
82
  ]
83
  lines += [
84
  "## Billing",
85
+ "Upgrade, change plan or manage billing from Settings under Plan and usage. "
86
+ "Annual billing is cheaper than monthly. Checkout may be switched off while billing is being finished, "
87
+ "in which case every workspace gets Studio limits.",
88
  "Refunds and billing problems are handled by the team: use the Talk to a human button in this chat.",
89
  ]
90
  body = "\n".join(lines)
91
  return Doc(
92
  id="plans-and-billing", title="Plans, pricing and billing", route="/pricing",
93
+ keywords=("price", "pricing", "plan", "plans", "free", "writer", "studio", "cost", "billing", "upgrade",
94
+ "limits", "annual", "monthly", "subscription", "refund", "cancel", "usage"),
95
  questions=("How much does Notestack cost?", "What is in the free plan?", "What are the plan limits?",
96
  "How do I upgrade?", "Is there annual billing?"),
97
  related_paths=("/app/settings",), headings=tuple(re.findall(r"^#{1,3}\s+(.+)$", body, re.M)), body=body,
backend/app/support/escalation.py CHANGED
@@ -28,9 +28,13 @@ _HUMAN = re.compile(
28
  | \bhuman+\b | \brepresent(?:i|a)tive\b
29
  """
30
  )
31
- _REFUND = re.compile(r"(?ix) \brefun\w*\b | \bchargeback\b | \bmoney\s+back\b | \bbilling\s+dispute\b | \bcancel\s+(?:my\s+)?(?:charge|payment)\b")
 
 
 
32
  _FRUSTRATION = re.compile(
33
- r"(?ix) \bcan.?t\s+help\b | \bcannot\s+help\b | \buseless\b | \bgive\s+up\b | \bnot\s+helpful\b | \bwaste\s+of\s+(?:time|money)\b | \bfed\s+up\b"
 
34
  )
35
  # A request verb, never a capability question: "what formats do you support" must stay quiet.
36
  _FEATURE = re.compile(
@@ -46,7 +50,9 @@ _FEATURE = re.compile(
46
  _BARE_ASK = re.compile(r"^\s*(?:support|help)\s*[!.?]*\s*$", re.I)
47
 
48
  # The bot offering the team in its own answer ("I can pass this to our team").
49
- _ANSWER_OFFERS_TEAM = re.compile(r"(?i)\b(?:pass|send|forward|route|hand)\b[^.?!]{0,30}\b(?:team|human|person|someone)\b|\bour\s+team\b")
 
 
50
 
51
 
52
  def classify_question(message: str) -> Reason | None:
@@ -70,7 +76,8 @@ def classify_answer(answer: str) -> Reason | None:
70
 
71
  # Claims the bot cannot back up: nothing is sent until the writer submits the form.
72
  _FALSE_ACTION = re.compile(
73
- r"(?i)\b(?:i(?:'ve|\s+have|\s+will|'ll)?\s+(?:sent|forwarded|passed|escalated|opened|created|notified|contacted|connected))\b"
 
74
  r"|\b(?:you(?:'re|\s+are)\s+(?:now\s+)?connected)\b|\bticket\s+(?:has\s+been|is)\b"
75
  )
76
 
@@ -99,8 +106,9 @@ def short_circuit_reply(reason: Reason, seed: int = 0) -> str:
99
  def handoff_prompt(reason: Reason) -> str:
100
  topic = "a refund or billing problem" if reason is Reason.REFUND else "talking to a person"
101
  return (
102
- f"You are the help assistant for Notestack. The writer is asking about {topic}. Reply in one or two short, warm "
103
- "sentences that respond to what they said, and tell them to use the form below to reach the team. Never say you "
104
- "have sent, forwarded, opened or escalated anything, and never say they are connected to someone: nothing is "
105
- "sent until they submit the form. Do not use em dashes. Do not answer the underlying question."
 
106
  )
 
28
  | \bhuman+\b | \brepresent(?:i|a)tive\b
29
  """
30
  )
31
+ _REFUND = re.compile(
32
+ r"(?ix) \brefun\w*\b | \bchargeback\b | \bmoney\s+back\b | \bbilling\s+dispute\b"
33
+ r" | \bcancel\s+(?:my\s+)?(?:charge|payment)\b"
34
+ )
35
  _FRUSTRATION = re.compile(
36
+ r"(?ix) \bcan.?t\s+help\b | \bcannot\s+help\b | \buseless\b | \bgive\s+up\b | \bnot\s+helpful\b"
37
+ r" | \bwaste\s+of\s+(?:time|money)\b | \bfed\s+up\b"
38
  )
39
  # A request verb, never a capability question: "what formats do you support" must stay quiet.
40
  _FEATURE = re.compile(
 
50
  _BARE_ASK = re.compile(r"^\s*(?:support|help)\s*[!.?]*\s*$", re.I)
51
 
52
  # The bot offering the team in its own answer ("I can pass this to our team").
53
+ _ANSWER_OFFERS_TEAM = re.compile(
54
+ r"(?i)\b(?:pass|send|forward|route|hand)\b[^.?!]{0,30}\b(?:team|human|person|someone)\b|\bour\s+team\b"
55
+ )
56
 
57
 
58
  def classify_question(message: str) -> Reason | None:
 
76
 
77
  # Claims the bot cannot back up: nothing is sent until the writer submits the form.
78
  _FALSE_ACTION = re.compile(
79
+ r"(?i)\b(?:i(?:'ve|\s+have|\s+will|'ll)?\s+"
80
+ r"(?:sent|forwarded|passed|escalated|opened|created|notified|contacted|connected))\b"
81
  r"|\b(?:you(?:'re|\s+are)\s+(?:now\s+)?connected)\b|\bticket\s+(?:has\s+been|is)\b"
82
  )
83
 
 
106
  def handoff_prompt(reason: Reason) -> str:
107
  topic = "a refund or billing problem" if reason is Reason.REFUND else "talking to a person"
108
  return (
109
+ f"You are the help assistant for Notestack. The writer is asking about {topic}. Reply in one or two "
110
+ "short, warm sentences that respond to what they said, and tell them to use the form below to reach "
111
+ "the team. Never say you have sent, forwarded, opened or escalated anything, and never say they are "
112
+ "connected to someone: nothing is sent until they submit the form. Do not use em dashes. "
113
+ "Do not answer the underlying question."
114
  )
backend/app/support/memory.py CHANGED
@@ -7,7 +7,7 @@ from sqlalchemy import select
7
  from sqlalchemy.orm import Session
8
 
9
  from app.models.base import utcnow
10
- from app.models.support import SupportConversation, SupportMessage
11
 
12
  RECENT_MESSAGES = 6 # about three turns
13
  RECENT_HOURS = 24
@@ -18,7 +18,8 @@ FOLD_BATCH = 4
18
  def recent_messages(db: Session, conv_id) -> list[SupportMessage]:
19
  rows = db.scalars(
20
  select(SupportMessage)
21
- .where(SupportMessage.conversation_id == conv_id, SupportMessage.created_at >= utcnow() - timedelta(hours=RECENT_HOURS))
 
22
  .order_by(SupportMessage.created_at.desc())
23
  .limit(RECENT_MESSAGES)
24
  ).all()
 
7
  from sqlalchemy.orm import Session
8
 
9
  from app.models.base import utcnow
10
+ from app.models.support import SupportMessage
11
 
12
  RECENT_MESSAGES = 6 # about three turns
13
  RECENT_HOURS = 24
 
18
  def recent_messages(db: Session, conv_id) -> list[SupportMessage]:
19
  rows = db.scalars(
20
  select(SupportMessage)
21
+ .where(SupportMessage.conversation_id == conv_id,
22
+ SupportMessage.created_at >= utcnow() - timedelta(hours=RECENT_HOURS))
23
  .order_by(SupportMessage.created_at.desc())
24
  .limit(RECENT_MESSAGES)
25
  ).all()
backend/app/support/prompts.py CHANGED
@@ -1,22 +1,37 @@
1
  """Prompts for the help bot. No em dashes in any of them: the model copies the style it is given."""
2
 
3
- ANSWER_PROMPT = """You are the Notestack help assistant. Notestack turns a writer's blog, newsletter or folder of markdown into a grounded research notebook, then into audio overviews, videos and launch kits.
 
4
 
5
  Write plain markdown. No JSON, no code fences. Never use em dashes.
6
 
7
- WHAT YOU KNOW: only the DOCUMENTS below. You cannot see the writer's notebooks, posts, sources, files, generated work or account. If asked about them, say you cannot see them and point to the chat inside their notebook, which cites their posts. Never guess about their content.
 
 
8
 
9
  GREETING: if the message is only a greeting or thanks, reply with one short friendly sentence. No lists.
10
 
11
- BE HELPFUL FIRST: if the documents contain anything relevant, answer from it, even partially. When they cover only part of the question, answer that part, then name the missing piece in one short sentence. Do not open with an apology.
 
 
12
 
13
- UNKNOWN: only when the documents contain nothing relevant, say in one or two short sentences what you cannot confirm and offer to pass it to the team, for example "I'm not sure whether that is supported. I can pass this to our team if you'd like." Never mention documents or sources. Only OFFER to contact the team. Never say you have sent, forwarded or opened anything: nothing is sent unless the writer submits the form. Never invent steps, buttons, prices or features.
 
 
 
 
14
 
15
- OFF TOPIC: if the message has nothing to do with Notestack or writing and publishing, reply with one short line steering back to Notestack. Do not answer the off topic question.
 
16
 
17
- COMPETITORS: represent Notestack only. Never recommend or name another product to use, even if a document mentions one. If Notestack cannot do something, say so plainly and point to the closest thing it does.
 
18
 
19
- HOW TO: you only give written instructions. Say where to go using the real menu names: Notebooks, Sources and Launchpad are in the left sidebar, Settings is at the bottom of the sidebar, Pricing is on the website menu. When the answer has more than one step, use a numbered markdown list, one step per line ("1.", "2."), with **bold** for button and menu names. One short intro sentence first. Never describe a button or page that the documents do not mention.
 
 
 
 
20
 
21
  DOCUMENTS:
22
  {docs}
@@ -34,7 +49,8 @@ citations: ids from this list that the answer actually used. Only ids from the l
34
  {doc_ids}
35
 
36
 
37
- escalate: true only when the answer admits it cannot help or offers to pass the question to the team, or the writer is clearly stuck or frustrated. False for normal answers.
 
38
  """
39
 
40
  SUMMARY_PROMPT = (
 
1
  """Prompts for the help bot. No em dashes in any of them: the model copies the style it is given."""
2
 
3
+ ANSWER_PROMPT = """You are the Notestack help assistant. Notestack turns a writer's blog, newsletter or folder of \
4
+ markdown into a grounded research notebook, then into audio overviews, videos and launch kits.
5
 
6
  Write plain markdown. No JSON, no code fences. Never use em dashes.
7
 
8
+ WHAT YOU KNOW: only the DOCUMENTS below. You cannot see the writer's notebooks, posts, sources, files, generated \
9
+ work or account. If asked about them, say you cannot see them and point to the chat inside their notebook, which \
10
+ cites their posts. Never guess about their content.
11
 
12
  GREETING: if the message is only a greeting or thanks, reply with one short friendly sentence. No lists.
13
 
14
+ BE HELPFUL FIRST: if the documents contain anything relevant, answer from it, even partially. When they cover only \
15
+ part of the question, answer that part, then name the missing piece in one short sentence. Do not open with an \
16
+ apology.
17
 
18
+ UNKNOWN: only when the documents contain nothing relevant, say in one or two short sentences what you cannot \
19
+ confirm and offer to pass it to the team, for example "I'm not sure whether that is supported. I can pass this to \
20
+ our team if you'd like." Never mention documents or sources. Only OFFER to contact the team. Never say you have \
21
+ sent, forwarded or opened anything: nothing is sent unless the writer submits the form. Never invent steps, \
22
+ buttons, prices or features.
23
 
24
+ OFF TOPIC: if the message has nothing to do with Notestack or writing and publishing, reply with one short line \
25
+ steering back to Notestack. Do not answer the off topic question.
26
 
27
+ COMPETITORS: represent Notestack only. Never recommend or name another product to use, even if a document \
28
+ mentions one. If Notestack cannot do something, say so plainly and point to the closest thing it does.
29
 
30
+ HOW TO: you only give written instructions. Say where to go using the real menu names: Notebooks, Sources and \
31
+ Launchpad are in the left sidebar, Settings is at the bottom of the sidebar, Pricing is on the website menu. When \
32
+ the answer has more than one step, use a numbered markdown list, one step per line ("1.", "2."), with **bold** \
33
+ for button and menu names. One short intro sentence first. Never describe a button or page that the documents do \
34
+ not mention.
35
 
36
  DOCUMENTS:
37
  {docs}
 
49
  {doc_ids}
50
 
51
 
52
+ escalate: true only when the answer admits it cannot help or offers to pass the question to the team, or the \
53
+ writer is clearly stuck or frustrated. False for normal answers.
54
  """
55
 
56
  SUMMARY_PROMPT = (
backend/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [tool.ruff]
2
- line-length = 120
3
  target-version = "py312"
4
 
5
  [tool.ruff.lint]
 
1
  [tool.ruff]
2
+ line-length = 140
3
  target-version = "py312"
4
 
5
  [tool.ruff.lint]
backend/tests/conftest.py CHANGED
@@ -11,6 +11,12 @@ os.environ["ELEVENLABS_API_KEY"] = ""
11
  os.environ["FIRECRAWL_API_KEY"] = ""
12
  os.environ["R2_PREFIX"] = ""
13
  os.environ["TYPESAFE_API_KEY"] = ""
 
 
 
 
 
 
14
  os.environ.setdefault("EMAIL_PROVIDER", "console")
15
  os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
16
  os.environ.setdefault("BILLING_ENABLED", "false")
 
11
  os.environ["FIRECRAWL_API_KEY"] = ""
12
  os.environ["R2_PREFIX"] = ""
13
  os.environ["TYPESAFE_API_KEY"] = ""
14
+ os.environ["STRIPE_SECRET_KEY"] = ""
15
+ os.environ["STRIPE_WEBHOOK_SECRET"] = ""
16
+ os.environ["STRIPE_PRICE_WRITER_MONTHLY"] = ""
17
+ os.environ["STRIPE_PRICE_WRITER_ANNUAL"] = ""
18
+ os.environ["STRIPE_PRICE_STUDIO_MONTHLY"] = ""
19
+ os.environ["STRIPE_PRICE_STUDIO_ANNUAL"] = ""
20
  os.environ.setdefault("EMAIL_PROVIDER", "console")
21
  os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
22
  os.environ.setdefault("BILLING_ENABLED", "false")
backend/tests/evals/support_live.py CHANGED
@@ -59,7 +59,8 @@ async def run(question: str) -> None:
59
  ):
60
  text += tok
61
  safe = esc.handoff_line_is_safe(text)
62
- print(f"[hand off, reason={reason.value}, safe={safe}]\n{strip_em_dashes(text.strip()) if safe else esc.short_circuit_reply(reason)}")
 
63
  return
64
  scored = get_retriever().retrieve(question)
65
  system = prompts.ANSWER_PROMPT.format(docs=_docs_block(scored, question), user_context="USER CONTEXT: (none)", summary="(none)")
 
59
  ):
60
  text += tok
61
  safe = esc.handoff_line_is_safe(text)
62
+ reply = strip_em_dashes(text.strip()) if safe else esc.short_circuit_reply(reason)
63
+ print(f"[hand off, reason={reason.value}, safe={safe}]\n{reply}")
64
  return
65
  scored = get_retriever().retrieve(question)
66
  system = prompts.ANSWER_PROMPT.format(docs=_docs_block(scored, question), user_context="USER CONTEXT: (none)", summary="(none)")