Spaces:
Runtime error
Runtime error
refactor: improve code readability and organization across multiple files
Browse files- backend/app/chat_memory/organize.py +1 -1
- backend/app/pipeline/generate.py +12 -4
- backend/app/pipeline/research.py +1 -1
- backend/app/routers/notebooks.py +1 -1
- backend/app/routers/support.py +12 -6
- backend/app/support/corpus.py +7 -5
- backend/app/support/escalation.py +16 -8
- backend/app/support/memory.py +3 -2
- backend/app/support/prompts.py +24 -8
- backend/pyproject.toml +1 -1
- backend/tests/conftest.py +6 -0
- backend/tests/evals/support_live.py +2 -1
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" #
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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
|
| 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]) #
|
| 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,
|
|
|
|
| 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)),
|
|
|
|
| 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)},
|
|
|
|
| 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)
|
|
|
|
| 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\
|
|
|
|
| 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)
|
|
|
|
| 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,
|
|
|
|
| 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.
|
| 85 |
-
"Checkout may be switched off while billing is being finished,
|
|
|
|
| 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",
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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+
|
|
|
|
| 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
|
| 103 |
-
"sentences that respond to what they said, and tell them to use the form below to reach
|
| 104 |
-
"have sent, forwarded, opened or escalated anything, and never say they are
|
| 105 |
-
"sent until they submit the form. Do not use em dashes.
|
|
|
|
| 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
|
| 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,
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
UNKNOWN: only when the documents contain nothing relevant, say in one or two short sentences what you cannot
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
OFF TOPIC: if the message has nothing to do with Notestack or writing and publishing, reply with one short line
|
|
|
|
| 16 |
|
| 17 |
-
COMPETITORS: represent Notestack only. Never recommend or name another product to use, even if a document
|
|
|
|
| 18 |
|
| 19 |
-
HOW TO: you only give written instructions. Say where to go using the real menu names: Notebooks, Sources and
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
| 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)")
|