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
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,19 @@ Assume Python commands run as `uv run …` or `uv run poe …` unless the venv i
| Lint / pre-commit | `uv run poe lint` / `uv run poe pre-commit` |
| Web format (Docker) | `docker compose run --rm --no-deps web npm run format` |
| Validate in Docker | `docker compose --profile validate run --rm validate` / `uv run poe validate-docker` |
| Web (dev) | `uv run poe up` → Vite :5173, API :8000 |
| Web (prod) | `uv run poe up-prod` / `docker compose up` → http://localhost:8080 |
| Web (dev) | `uv run poe up` → UI :14321, API :14322, docs :14323 (no nginx) |
| Web (prod) | `uv run poe up-prod` / `docker compose up` → http://localhost:8080/ (API `/api`, docs `/docs`) |
| Rebuild images | `uv run poe build` (web + backend + docs + validate; exits when done) |
| Import words (JSON) | `cat records.json \| uv run soju import words --topic <id> --stdin-json` |
| Import verbs (JSON) | `cat verbs.json \| uv run soju import verbs --stdin-json` |
| Assign course levels | `uv run soju levels set --level 1A --all-unassigned` · `--kind grammar` |
| Promote local words | `uv run soju promote --topic <id>` |
| Backend API (host) | `uv sync --group backend` · `uv run soju backend --config config/backend.yaml` |
| Backend API (host) | `uv sync --group backend` · `uv run soju backend --config docker/soju/backend.dev.yaml` |
| CLI help | `uv run soju --help` · `uv run soju <subcommand> --help` |
| Python tests | `uv run poe test` (unit + offline system; skips LLM) |
| System / LLM / coverage | `uv run poe test-system` · `uv run poe test-llm` · `uv run poe test-all` · `uv run poe coverage` |
| Build embedding cache | `uv run poe embed-index` (requires Ollama + embed model) |
| Build / serve docs | `uv run poe docs` · `uv run poe docs-serve` |
| Build / serve docs | `uv run poe docs` · `uv run poe docs-serve` (host live :14323; Compose prod: http://localhost:8080/docs/) |

Docker Compose **project name:** `soju` (`name: soju` in `docker-compose.yml`). Containers use the `soju-` prefix; named volumes use `soju_`.

Expand All @@ -65,7 +66,7 @@ One console entry is installed by `uv sync`: **`soju`**. Invoke as `uv run soju
| **`embed-index`** | Build Ollama embedding cache for Practice retrieval (`data/cache/embeddings/`) |
| **`backend`** | Run FastAPI Soju API (LLM proxy + TTS; needs `uv sync --group backend`) |

**Poe shortcuts:** `validate`, `validate-schemas`, `validate-align`, `validate-registry`, `validate-docker`, `test`, `pre-commit`, `lint`, `import-words`, `import-verbs`, `translate-words`, `embed-index`, `docs`, `docs-serve`.
**Poe shortcuts:** `validate`, `validate-schemas`, `validate-align`, `validate-registry`, `validate-docker`, `build`, `test`, `pre-commit`, `lint`, `import-words`, `import-verbs`, `translate-words`, `embed-index`, `docs`, `docs-serve`.

## Documentation

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ Requires [uv](https://docs.astral.sh/uv/) and Docker. From the repo root:

```bash
uv sync
uv run poe up # Vite :5173 + API :8000
uv run poe up-prod # nginx :8080 (same as docker compose up)
uv run poe up # Vite :14321 + API :14322 + docs :14323 (no nginx)
uv run poe up-prod # nginx :8080 only — UI /, API /api, docs /docs
uv run poe build # rebuild container images and exit
```

Validation, tests, docs, and the rest of the tooling live in the Sphinx guide:
Validation, tests, docs, and the rest of the tooling live in the Sphinx guide
(dev: [http://localhost:14323/](http://localhost:14323/); prod: [http://localhost:8080/docs/](http://localhost:8080/docs/)):

```bash
uv run poe docs-serve
Expand Down
4 changes: 2 additions & 2 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Copy to .env for local Vite outside Docker (optional).
# Models / tutor prompt / TTS voice: GET /v1/soju/client-config from the Soju API.
PUBLIC_AI_ENABLED=true
# Prod (nginx): http://localhost:8080 · Dev (poe up): http://localhost:8000
PUBLIC_AI_BASE_URL=http://localhost:8080
# Dev (poe up): http://localhost:14322 · Prod (nginx): /api
PUBLIC_AI_BASE_URL=http://localhost:14322
PUBLIC_TTS_ENGINE=local
11 changes: 2 additions & 9 deletions apps/web/src/lib/chat-context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AiCompletionRequest, AiMessage } from '$lib/ai/types';
import type { ChatTurn } from '$lib/chat';
import { chatKeepRecent, chatSummaryTrigger } from '$lib/config';
import { chatKeepRecent, chatSummarizePrompt, chatSummaryTrigger } from '$lib/config';
import { getItem, removeItem, setItem } from '$lib/storage';

const MEMORY_KEY = 'chat-memory';
Expand All @@ -18,13 +18,6 @@ export interface ChatContextThresholds {

type CompleteFn = (request: AiCompletionRequest) => Promise<string>;

const SUMMARIZE_SYSTEM = [
'You compress a Korean tutoring chat into a short memory note for the teacher AI.',
'Preserve: topics the student asked about, grammar forms, Korean+English examples, student goals/focus, open questions.',
'Omit: greetings, encouragement fluff, repeated explanations.',
'Max ~12 short bullet lines. Plain text only (use "-" bullets). No markdown headings.',
].join(' ');

function defaultThresholds(): ChatContextThresholds {
return { trigger: chatSummaryTrigger, keepRecent: chatKeepRecent };
}
Expand Down Expand Up @@ -90,7 +83,7 @@ async function summarizeTurns(previousSummary: string | null, turns: ChatTurn[],
const text = await complete({
model,
messages: [
{ role: 'system', content: SUMMARIZE_SYSTEM },
{ role: 'system', content: chatSummarizePrompt },
{ role: 'user', content: userContent },
],
temperature: 0.2,
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/lib/chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { aiTutorName, defaultChatSystemPrompt } from '$lib/config';
import { aiTutorName, chatVocabSuffix, defaultChatSystemPrompt } from '$lib/config';
import { buildVocabularySummary } from '$lib/data/loader';
import { getItem, removeItem, setItem } from '$lib/storage';

Expand Down Expand Up @@ -26,7 +26,8 @@ export function buildChatSystemPrompt(): string {
const vocabHint = [...vocabulary.words.slice(0, 20).map((word) => word.hangul), ...vocabulary.verbs.slice(0, 10).map((verb) => verb.hangul)].join(', ');

const prompt = applyTutorName(defaultChatSystemPrompt);
return `${prompt}\n\nKnown vocabulary includes: ${vocabHint}`;
const suffix = chatVocabSuffix.replaceAll('{{vocab_hint}}', vocabHint);
return `${prompt}\n\n${suffix}`;
}

export async function loadChatMessages(): Promise<ChatTurn[]> {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/components/ChatConversation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { buildModelMessages, clearChatMemory, ensureChatMemory, loadChatMemory, needsSummarization, type ChatMemory } from '$lib/chat-context';
import { type ChatTurn, loadChatMessages, saveChatMessages } from '$lib/chat';
import { formatChatContent } from '$lib/format-chat-content';
import { aiDisclaimer } from '$lib/config';

type ReplyPhase = 'idle' | 'summarizing' | 'thinking' | 'streaming';

Expand Down Expand Up @@ -150,7 +151,7 @@
<p class="chat-conversation__error" role="alert">{error}</p>
{/if}

<p class="chat-ai-note">AI can make mistakes. Please verify the output.</p>
<p class="chat-ai-note">{aiDisclaimer}</p>

<form class="chat-form" class:chat-form--compact={compact} use:nativeSubmit>
{#if compact}
Expand Down
34 changes: 24 additions & 10 deletions apps/web/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,30 +34,32 @@ export function resolveTtsEngine(raw?: string): TtsEngine {
/** When true, Practice and Chat are available (requires Soju backend + LLM at runtime). */
export const aiEnabled = firstDefined(PUBLIC_AI_ENABLED, dynamicPublicEnv.PUBLIC_OLLAMA_ENABLED) === 'true';

/** Browser-reachable Soju API root (nginx → FastAPI in prod). */
export const sojuApiBaseUrl = (firstDefined(PUBLIC_AI_BASE_URL, dynamicPublicEnv.PUBLIC_OLLAMA_BASE_URL) ?? 'http://localhost:8080').replace(/\/$/, '');
/**
* Browser-reachable Soju API root (nginx ``/api`` → FastAPI).
* Never point this at Ollama — the backend is the only AI egress.
* ``PUBLIC_OLLAMA_BASE_URL`` is a legacy alias for the same Soju base URL.
*/
export const sojuApiBaseUrl = (firstDefined(PUBLIC_AI_BASE_URL, dynamicPublicEnv.PUBLIC_OLLAMA_BASE_URL) ?? 'http://localhost:14322').replace(/\/$/, '');

const envChatThresholds = resolveChatContextThresholds(dynamicPublicEnv.PUBLIC_AI_CHAT_SUMMARY_TRIGGER, dynamicPublicEnv.PUBLIC_AI_CHAT_KEEP_RECENT);

export const defaultChatTutorName = 'Hee-jae (희재)';

/** Minimal stub until ``GET /v1/soju/client-config`` supplies ``system_prompt`` from prompts.yaml. */
const envSystemPrompt =
firstDefined(dynamicPublicEnv.PUBLIC_AI_SYSTEM_PROMPT, dynamicPublicEnv.PUBLIC_OLLAMA_SYSTEM_PROMPT) ||
[
'You are {{tutor_name}}, a friendly Korean language teacher for beginners.',
'Keep replies short (2–4 sentences). If the question is vague, ask one clarifying question first.',
'Once the topic is clear, give a brief explanation and at most one example (Korean + English).',
'Be warm and patient with light empathy; a gentle emoji is fine when it fits—keep encouragement brief.',
'Prefer Soju vocabulary when helpful. Mix Korean and English naturally.',
'Use plain text; **bold** for key Korean forms is fine. Write → for conjugation; avoid LaTeX and ---.',
].join(' ');
'You are {{tutor_name}}, a friendly Korean language teacher for beginners.';

/** Overridable at runtime via ``GET /v1/soju/client-config`` (see ``applyClientConfig``). */
export let aiModel = firstDefined(dynamicPublicEnv.PUBLIC_AI_MODEL, dynamicPublicEnv.PUBLIC_OLLAMA_MODEL) ?? 'gemma4:e4b';
export let aiEmbedModel = firstDefined(dynamicPublicEnv.PUBLIC_AI_EMBED_MODEL, dynamicPublicEnv.PUBLIC_OLLAMA_EMBED_MODEL) ?? 'nomic-embed-text';
export let aiApiMode: AiApiMode = firstDefined(dynamicPublicEnv.PUBLIC_AI_API_MODE) === 'conversations' ? 'conversations' : 'chat-completions';
export let aiTutorName = firstDefined(dynamicPublicEnv.PUBLIC_AI_TUTOR_NAME) ?? defaultChatTutorName;
export let defaultChatSystemPrompt = envSystemPrompt;
export let chatSummarizePrompt =
'You compress a Korean tutoring chat into a short memory note for the teacher AI. Preserve: topics the student asked about, grammar forms, Korean+English examples, student goals/focus, open questions. Omit: greetings, encouragement fluff, repeated explanations. Max ~12 short bullet lines. Plain text only (use "-" bullets). No markdown headings.';
export let chatVocabSuffix = 'Known vocabulary includes: {{vocab_hint}}';
export let aiDisclaimer = 'AI can make mistakes. Please verify the output.';
export let chatSummaryTrigger = envChatThresholds.trigger;
export let chatKeepRecent = envChatThresholds.keepRecent;
export let localTtsVoice = firstDefined(dynamicPublicEnv.PUBLIC_TTS_PIPER_VOICE) ?? 'ko-KR-SunHiNeural';
Expand All @@ -72,6 +74,9 @@ export type SojuClientConfigPayload = {
embed_model?: string;
tutor_name?: string;
system_prompt?: string;
chat_summarize_prompt?: string;
chat_vocab_suffix?: string;
ui_disclaimer?: string;
chat_summary_trigger?: number;
chat_keep_recent?: number;
tts_default_voice?: string;
Expand All @@ -94,6 +99,15 @@ export function applyClientConfig(payload: SojuClientConfigPayload): void {
if (typeof payload.system_prompt === 'string' && payload.system_prompt.trim()) {
defaultChatSystemPrompt = payload.system_prompt;
}
if (typeof payload.chat_summarize_prompt === 'string' && payload.chat_summarize_prompt.trim()) {
chatSummarizePrompt = payload.chat_summarize_prompt.trim();
}
if (typeof payload.chat_vocab_suffix === 'string' && payload.chat_vocab_suffix.trim()) {
chatVocabSuffix = payload.chat_vocab_suffix.trim();
}
if (typeof payload.ui_disclaimer === 'string' && payload.ui_disclaimer.trim()) {
aiDisclaimer = payload.ui_disclaimer.trim();
}
if (typeof payload.tts_default_voice === 'string' && payload.tts_default_voice.trim()) {
localTtsVoice = payload.tts_default_voice.trim();
}
Expand Down
Loading