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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/app/agent/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class AgentRequest(BaseModel):
model: str | None = None
trigger: str | None = None
stitch_message_id: str | None = None
thread_id: str | None = None


# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -548,6 +549,9 @@ async def agent_chat(request: AgentRequest) -> StreamingResponse:
# hit rate (cached_tokens) — see _stream_agent logging.
"stream_options": {"include_usage": True},
"extra_body": {
# Prompt cache key for OpenRouter sticky routing + prefix caching.
# Same thread_id across turns = same cache shard.
"prompt_cache_key": request.thread_id or "default",
# OpenRouter-native usage accounting — includes cached_tokens / cost.
"usage": {"include": True},
"reasoning": {"budget_tokens": 256},
Expand Down
28 changes: 24 additions & 4 deletions backend/app/document_search/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import httpx
from fastapi import APIRouter, HTTPException
from typing import Any

from pydantic import BaseModel

from app.settings import settings
Expand All @@ -23,15 +25,16 @@ class DocumentSearchRequest(BaseModel):
query: str


class Passage(BaseModel):
class SearchDocumentResult(BaseModel):
doc_id: str
doc_name: str
title: str
content: str
doc_description: str | None = None
page_count: int = 0
structure: list[Any]


class DocumentSearchResponse(BaseModel):
passages: list[Passage]
documents: list[SearchDocumentResult]
routed_doc_ids: list[str]


Expand All @@ -51,3 +54,20 @@ async def document_search(req: DocumentSearchRequest) -> DocumentSearchResponse:
raise HTTPException(status_code=502, detail=f"Document search upstream error: {e}")

return DocumentSearchResponse(**resp.json())


@router.get("/documents/{doc_id}/pages")
async def get_document_pages(doc_id: str, pages: str):
"""Proxy: get page content from PageIndex."""
key = _resolve_key(None, settings.pageindex_api_key, "PageIndex API key")
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(
f"{settings.pageindex_base_url}/doc/{doc_id}/pages",
params={"pages": pages},
headers={"X-API-Key": key},
)
resp.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Page content upstream error: {e}")
return resp.json()
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.56.1",
"unzipper": "^0.12.3",
"vite": "^8.0.0",
Expand Down
356 changes: 178 additions & 178 deletions frontend/pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/src/features/agent/application/useZoberChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ export function useZoberChat(args: ZoberChatArgs) {
// into a single growing assistant message instead of N copies.
trigger,
stitch_message_id: messageId ?? null,
thread_id: threadId,
},
}
},
Expand Down
28 changes: 14 additions & 14 deletions frontend/src/features/agent/lib/prompt/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ export function toDateOnly(iso?: string): string {

export function learningResourceProtocolBlock(): string {
return tag('learning_resource_protocol', [
'Learning tips and vocabulary methods = call `search_document` FIRST, then ground the answer in the returned passages.',
'Learning tips and vocabulary methods = call `search_document` FIRST, then fetch relevant pages via `get_page_content`, then ground the answer in the fetched content.',
'- **TRIGGER:** any question about HOW to learn Chinese — memorizing words, building vocabulary, planning a study schedule, retention techniques, effective daily habits, or any request for a recommended video/lesson on a specific learning topic (e.g. "how do I memorize tones", "recommend a video about spaced repetition", "how should I plan my study day").',
'- **DO NOT answer from training data alone.** The knowledge base contains curated content from language-learning experts; prefer retrieved passages over generic advice.',
'- **NO REDUNDANT CALLS:** if a prior `search_document` result in this conversation already covers the topic, reuse those passages.',
'- **SHOW THE SOURCE URL** when one appears in the returned passages — link the learner directly to the original video.',
'- **DO NOT answer from training data alone.** The knowledge base contains curated content from language-learning experts; prefer retrieved content over generic advice.',
'- **NO REDUNDANT CALLS:** NEVER call `search_document` again for a `doc_id` already returned in this conversation. Reuse the structures in context — go straight to `get_page_content`. Only re-query `search_document` for a genuinely new topic not covered by any document already in context.',
'- **SHOW THE SOURCE URL** when one appears in the fetched page content — link the learner directly to the original video.',
])
}

Expand Down Expand Up @@ -74,18 +74,18 @@ export function roleBlock(surface: PromptSurface): string {

/**
* Grammar handling rule. Both surfaces ship `search_document`, so both answer
* grammar the same way — from retrieved passages, never from training data.
* grammar the same way — from retrieved content, never from training data.
*/
export function grammarProtocolBlock(): string {
return tag('grammar_protocol', [
'Grammar = the single source of truth is `search_document`, NEVER your own training knowledge.',
'Grammar = the single source of truth is `search_document` + `get_page_content`, NEVER your own training knowledge.',
'- **SCOPE:** grammar means rules, particles, sentence patterns, word order. A bare single-word meaning ("what does 不 mean?") is vocabulary — answer it normally, no `search_document` needed. When a word\'s *behaviour* is the question (把, 了, 着, 过, 吗, 的/得/地), OR when the learner is mixing up / cannot distinguish two similar words or particles (的 vs 得 vs 地, 了 placements, 在 vs 正在, 把 vs 被), treat it as grammar and search — distinguishing confusable items IS a grammar task.',
'- **TRIGGER — explicit OR implicit.** Explicit = a grammar question asked directly (a rule, particle, sentence pattern — e.g. 把 construction, 了/着/过, complements, word order). Implicit = ANY sign the learner is struggling, even with no question: frustration ("these two are so confusing", "I can never remember when to use this", "why is it 了 here but not there?"), repeated misuse of a pattern, or confusion between similar forms. In BOTH cases call `search_document` FIRST with a concise natural-language query, THEN address the struggle from the passages. Do not wait for a formal "explain X" request.',
'- **GROUND:** answer ONLY from the returned passages. Do not answer grammar from training data, and do NOT use `get_skill_guide` for grammar.',
'- **NO REDUNDANT CALLS:** if an earlier `search_document` result in this conversation already contains the passages needed to answer, reuse itdo NOT call again for the same point. Only re-query for a genuinely new grammar point or when prior passages are insufficient.',
'- **ALWAYS SHOW THE SOURCE URL:** the passages include the YouTube link(s) the content was derived from. Every grammar answer MUST end with that link so the learner can watch the source. Use links found verbatim in the passages only — never invent or guess a URL; if a link is split across lines, rejoin into a single `https://youtu.be/<id>` URL.',
'- **EXAMPLE (explicit) — the rule holds even for "easy" questions.** Learner: "does 吗 make a sentence a question?" → call `search_document("吗 question particle")` FIRST, ground the answer in the passage, end with its URL. Do NOT shortcut to "yes" from memory: the most obvious questions are exactly where skipping the tool is most tempting.',
'- **EXAMPLE (implicit) — detect struggle with no question asked.** Learner: "ugh, 的 得 地 are so confusing, I can never tell them apart." This is not phrased as a question, but it is a clear grammar struggle → call `search_document("difference between 的 得 地 usage")` FIRST, then explain the distinction grounded in the passages and end with the URL. Treat venting/confusion as a trigger, not small talk.',
'- **TRIGGER — explicit OR implicit.** Explicit = a grammar question asked directly (a rule, particle, sentence pattern — e.g. 把 construction, 了/着/过, complements, word order). Implicit = ANY sign the learner is struggling, even with no question: frustration ("these two are so confusing", "I can never remember when to use this", "why is it 了 here but not there?"), repeated misuse of a pattern, or confusion between similar forms. In BOTH cases call `search_document` FIRST with a concise natural-language query, THEN call `get_page_content` to fetch the relevant pages, and address the struggle from the fetched content. Do not wait for a formal "explain X" request.',
'- **GROUND:** call `get_page_content(doc_id, "5-7")` to fetch the relevant pages, then answer ONLY from the fetched content. Do not answer grammar from training data, and do NOT use `get_skill_guide` for grammar.',
'- **NO REDUNDANT CALLS:** NEVER call `search_document` again for a `doc_id` already returned in this conversation. The structures from that document are already in contextgo straight to `get_page_content` to fetch the relevant pages. Only re-query `search_document` for a genuinely new grammar point not covered by any document `doc_id` already returned.',
'- **ALWAYS SHOW THE SOURCE URL:** the fetched page content includes the YouTube link(s) the content was derived from. Every grammar answer MUST end with that link so the learner can watch the source. Use links found verbatim in the fetched content only — never invent or guess a URL; if a link is split across lines, rejoin into a single `https://youtu.be/<id>` URL.',
'- **EXAMPLE (explicit) — the rule holds even for "easy" questions.** Learner: "does 吗 make a sentence a question?" → call `search_document("吗 question particle")` FIRST, then call `get_page_content` to fetch the relevant pages, end with the URL. Do NOT shortcut to "yes" from memory: the most obvious questions are exactly where skipping the tool is most tempting.',
'- **EXAMPLE (implicit) — detect struggle with no question asked.** Learner: "ugh, 的 得 地 are so confusing, I can never tell them apart." This is not phrased as a question, but it is a clear grammar struggle → call `search_document("difference between 的 得 地 usage")` FIRST, then call `get_page_content` to fetch the relevant pages, explain the distinction grounded in the fetched content, and end with the URL. Treat venting/confusion as a trigger, not small talk.',
])
}

Expand All @@ -99,7 +99,7 @@ export function instructionsBlock(surface: PromptSurface): string {
'- **Call `recall_memory()` proactively when the user asks about their goals, preferences, history, or learning context** — do not rely solely on the Memory Summary above.',
'- Answer general learning questions, including grammar (see `<grammar_protocol>`) and learning strategies (see `<learning_resource_protocol>`). Only DECLINE questions about a specific YouTube lesson\'s content or lesson-specific actions (exercises, shadowing) — point the user into the lesson for those. Boundary: "what does 把 mean / how does 了 work" = general grammar, ANSWER it; "what does the 3rd sentence of my Daily Conversation lesson mean / start a drill" = lesson-specific, REDIRECT into the lesson.',
'- For grammar, follow `<grammar_protocol>` — never `get_skill_guide`.',
'- For learning tips, study planning, vocabulary methods, or video recommendations, follow `<learning_resource_protocol>` — call `search_document` first.',
'- For learning tips, study planning, vocabulary methods, or video recommendations, follow `<learning_resource_protocol>` — call `search_document` first, then `get_page_content` to read relevant pages.',
'- If asked for tips, advice, or a topic covered in core guidelines or skill guides, ALWAYS use get_core_guidelines() or get_skill_guide() to provide accurate info.',
'- Do not re-call `get_core_guidelines` or `get_skill_guide` if already loaded this session.',
]),
Expand All @@ -114,7 +114,7 @@ export function instructionsBlock(surface: PromptSurface): string {
'- **Call `get_core_guidelines()` at session start — loads SLA principles, feedback templates, and session protocols.**',
'- **ALWAYS call `get_skill_guide({ skill })` BEFORE giving advice, tips, or answering "how-to" questions about specific skills (tones, pronunciation, vocabulary, listening, speaking, characters).**',
'- For grammar, follow `<grammar_protocol>` — never `get_skill_guide`.',
'- For learning tips, study planning, vocabulary methods, or video recommendations, follow `<learning_resource_protocol>` — call `search_document` first.',
'- For learning tips, study planning, vocabulary methods, or video recommendations, follow `<learning_resource_protocol>` — call `search_document` first, then `get_page_content` to read relevant pages.',
'- Chain tools when needed, but always end with a user-visible response.',
'- Use get_study_context (composite) before suggesting exercises — it covers all data in one call.',
'- Save important user observations with save_memory().',
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/features/agent/lib/tools/data/getPageContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from 'zod'
import { buildTool } from '@/features/agent/lib/tools/types'
import { API_BASE } from '@/shared/lib/config'

export const GetPageContentSchema = z.object({
doc_id: z.string().describe('The document ID to get page content from'),
pages: z.string().describe('Page range in format "5-7", "3,8", or "12". Use tight ranges; never fetch the whole document.'),
})

export type GetPageContentArgs = z.infer<typeof GetPageContentSchema>

interface PageContent {
page: number
content: string
}

export async function executeGetPageContent(
args: GetPageContentArgs,
): Promise<PageContent[] | { error: string }> {
const resp = await fetch(
`${API_BASE}/api/documents/${args.doc_id}/pages?pages=${encodeURIComponent(args.pages)}`,
)
if (!resp.ok)
return { error: `Failed to get page content (${resp.status})` }
return await resp.json() as PageContent[]
}

export const getPageContentTool = buildTool({
name: 'get_page_content',
description: 'Get the text content of specific pages by doc_id and page range. Call this AFTER search_document returns a doc_id you want to deep-dive into. Use tight ranges: e.g. "5-7" for pages 5 to 7, "3,8" for pages 3 and 8, "12" for page 12. NEVER fetch large ranges or the whole document — that wastes tokens and time. If you need more context, make multiple tight requests.',
inputSchema: GetPageContentSchema,
isConcurrencySafe: () => true,
isReadOnly: () => true,
// A single page of Chinese text can be 3000-5000 chars; a 3-page range can exceed 15000.
maxResultSizeChars: 100_000,
searchHint: 'page content, text by page number, document section text',
execute: async input => executeGetPageContent(input as GetPageContentArgs),
})
27 changes: 16 additions & 11 deletions frontend/src/features/agent/lib/tools/data/searchDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,39 @@ export const SearchDocumentSchema = z.object({

export type SearchDocumentArgs = z.infer<typeof SearchDocumentSchema>

interface Passage { doc_id: string, doc_name: string, title: string, content: string }
interface SearchDocumentResult {
doc_id: string
doc_name: string
doc_description?: string
page_count: number
structure: any[]
}

export async function executeSearchDocument(
args: SearchDocumentArgs,
): Promise<{ passages: Passage[] } | { error: string }> {
): Promise<{ documents: SearchDocumentResult[] } | { error: string }> {
const resp = await fetch(`${API_BASE}/api/document-search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: args.query }),
})
if (!resp.ok)
return { error: `Document search failed (${resp.status})` }
const data = await resp.json() as { passages: Passage[] }
if (!data.passages?.length)
return { error: 'No relevant content found in the indexed documents.' }
return { passages: data.passages }
const data = await resp.json() as { documents: SearchDocumentResult[] }
if (!data.documents?.length)
return { error: 'No relevant documents found for this query.' }
return { documents: data.documents }
}

export const searchDocumentTool = buildTool({
name: 'search_document',
description: 'Search the knowledge base and return relevant verbatim passages to ground your answer. The knowledge base holds three kinds of documents: (1) the ShadowLearn app user manual — how to use features of the app; (2) a grammar-point reference compiled from well-known language-learning YouTube channels; (3) learning strategy content covering vocabulary acquisition methods, memorization techniques, study scheduling, and effective learning habits. Call this for: "how do I use shadowing mode?" (manual), "explain the 把 construction" (grammar), or "how do I memorize Chinese words / plan my study day / learn vocabulary effectively?" (learning strategies). Also call this when the user asks for a recommended video or lesson on any of these topics. Pass a natural-language question, not keywords. Ground your answer in the returned passages and cite the source; do not add facts beyond them.',
description: 'Search the knowledge base and return document structures (tree index with titles, page ranges, summaries) for matching documents. Each result includes a doc_id — use get_page_content(doc_id, "5-7") to read specific sections. The knowledge base holds three kinds of documents: (1) the ShadowLearn app user manual — how to use features of the app; (2) a grammar-point reference compiled from well-known language-learning YouTube channels; (3) learning strategy content covering vocabulary acquisition methods, memorization techniques, study scheduling, and effective learning habits. Call this for: "how do I use shadowing mode?" (manual), "explain the 把 construction" (grammar), or "how do I memorize Chinese words / plan my study day?" (learning strategies). Pass a natural-language question, not keywords. Ground your answer in content you fetch via get_page_content; do not add facts beyond the retrieved sections.',
inputSchema: SearchDocumentSchema,
isConcurrencySafe: () => true,
isReadOnly: () => true,
// RAG passages are the source of truth the agent fetched to answer; never chop
// them at produce-time. Context is managed downstream by compaction (stale
// passages are pruned only after being summarized away, never the active one).
// Structure can be large (50-100 nodes per doc, 30-150KB for 3 docs). Never
// truncate at produce-time — context management prunes stale results downstream.
maxResultSizeChars: Number.MAX_SAFE_INTEGER,
searchHint: 'search app manual, how to use feature, grammar point explanation, construction pattern language reference, vocabulary memorization methods, study planning, learning tips, effective Chinese learning strategies',
searchHint: 'knowledge base, document search, grammar reference, user manual, learning guide, shadowing guide, resource lookup',
execute: async (input, _context) => executeSearchDocument(input as SearchDocumentArgs),
})
Loading
Loading