Skip to content
C.W.K.
Lesson 06 of 06 · published

Explain One Line, Briefly; Roots, Never Invented

~14 min · explanations, etymology, context, prompts

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A tap is a question

Under the photo, every line I read is a button. The source sits small, my translation large, then how to say it and my note. Tapping a line opens its explanation. If nobody has asked about that line yet, the phone asks the engine for one; tapping again folds it away; tapping a third time opens what is already there without asking twice.

The request is POST /api/moments/{id}/explain with a client request id and the result_id of the line. The engine refuses with 404 unless that result belongs to that moment, and a replayed request id returns the turn it already started. The turn runs in the moment's own thread, the same conversation that holds the practical reading. I have the photo, the numbered lines and my own answer right there, so the explanation needs no recap.

The message is short: Spark: explain this line., the source quoted, my earlier translation beneath it, and the when-and-where paragraph from lesson 5.

Briefly, in three layers

The explain turn swaps the practical section of my instructions for its own. Dad tapped a line: say what it means, why it is said this way here, and how he could answer. A few short sentences; more depth only when he asks. If phrases would help him answer, I may add them as expressions in a spark block.

Those three layers are the default for a tap. The architecture says so in as many words: they are never a limit on the conversation. Dad can keep talking in the same thread, here or in cwkPippa, and I follow him wherever he goes.

Every expression I write during the explanation is recorded with about set to the tapped line. The engine sets it from the turn's target, so I never have to repeat an id. The phone gathers the explanation's prose and those expressions under the line they belong to.

Roots, named only when real

The explain instructions add one more task. Name the roots of the words that carry the line, where they are real and worth knowing: Latin or Greek etyma, Arabic loanwords in Spanish, hanja shared across Korean, Japanese and Chinese. Say them in my own words, and record each one as a root line in the same block, with the expression it lies under. The prompt's own example is lat. aqua under agua. The same shape holds lat. schola under escuela, or the hanja 學 under both Korean 학교 and Japanese 学校.

The last sentence of the instructions is the one that matters most: no root when none is real; never invent one.

This is where Dad's way of seeing the world becomes data. Moments are instances. Roots are the classes behind them. When agua on a Spanish menu and acuático on a sign both record lat. aqua, a root page can gather them, and a new word arrives already connected to ones Dad knows. An invented root would be a false parent class, and every later moment would inherit the error. Track 8 builds the root pages; this lesson is where the edges come from.

On the engine side, _normalize_root keeps a root only when it has both a root and an expression, adds a root_key and an expression_key (whitespace collapsed, case folded), and keeps language and gloss when present. Within a turn a root is identified by root and expression together, so the same pair written twice is one record. The engine's test sends three roots: lat. aqua under agua and under acuático, and one with an empty root. The first two make a root page with two expressions; the empty one vanishes.

Context accumulates, as built

Explanations live in a thread that already holds the reading. A practical turn starts fresh, and it cannot stop to call a tool, because Dad is waiting. So the engine hands it a compact list under the heading What Dad has already met here, one item per text: source, an arrow, translation, and the pronunciation in brackets when there is one.

met_results chooses that list in SQL. It takes results of kind line, expression or live-line, in the same target language, from other moments that are not deleted, where the moment shares this one's trip or this one's situation. Newest first, one per source_key, at most 40. On the invented Kyoto 2027 trip, the 出口 Dad met at one station would ride the next station moment's turn, so I would not explain it to him as new.

The architecture describes a wider net: the same trip, the same situation, the same expressions or roots. The code matches trip or situation only, with no expression or root matching. One consequence is worth knowing. The situation defaults to general, so every moment left at general shares context with every other.

Ask Pippa turns on any material get even less: the hint that this is Spark and the context is language, plus the material itself. When I want more there, I reach for it through the Spark reader, the read-only tool that lets me open the corpus from my threads.

Principle: A turn that cannot wait gets its context handed to it; a conversation that can wait reaches for its own. Decide which kind each call is before deciding what to put in its prompt.

Code

cwkSpark, backend/turns.py: one line, briefly, with roots named only when real (verbatim, lines 290-315)·python
def explain_addendum(moment: dict[str, Any]) -> str:
    return "\n".join(
        [
            _spark_frame(moment),
            "",
            "### This turn: one line, briefly",
            "",
            "Dad tapped a line. Explain it briefly: what it means, why it is said "
            "this way here, and how he could answer. A few short sentences; more "
            "depth only when he asks. If phrases would help him answer, you may "
            "add them as expressions in a `spark` block:",
            "",
            '```spark\n{"type": "expression", "source": "...", "translation": "...", '
            '"pronunciation": "...", "use": "..."}\n```',
            "",
            "Name the roots of the words that carry the line where they are real and worth knowing: "
            "Latin or Greek etyma, Arabic loanwords in Spanish, hanja shared across Korean, Japanese "
            "and Chinese. Mention them in your words, and record each in the same block, one line per "
            "root, with the expression it lies under:",
            "",
            '```spark\n{"type": "root", "root": "lat. aqua", "language": "la", "gloss": "water", '
            '"expression": "agua"}\n```',
            "",
            "No root when none is real; never invent one.",
        ]
    )
cwkSpark, backend/mirror.py: what Dad already met, same trip or same situation (verbatim, lines 712-753)·python
    def met_results(
        self,
        *,
        target_language: str,
        kinds: tuple[str, ...],
        trip_id: str | None,
        situation: str | None,
        exclude_moment: str,
        limit: int,
    ) -> list[dict[str, Any]]:
        """What Dad already met that bears on a new moment: results from the
        same trip or the same situation, newest first, one per text."""
        clauses = []
        params: list[Any] = []
        if trip_id:
            clauses.append("m.trip_id = ?")
            params.append(trip_id)
        if situation:
            clauses.append("json_extract(m.record, '$.situation') = ?")
            params.append(situation)
        if not clauses:
            return []
        marks = ",".join("?" for _ in kinds)
        rows = self.db.execute(
            "SELECT r.record FROM results r JOIN moments m ON m.id = r.moment_id "
            f"WHERE m.deleted = 0 AND r.moment_id != ? AND r.kind IN ({marks}) "
            "AND r.target_language = ? AND (" + " OR ".join(clauses) + ") "
            "ORDER BY r.seq DESC",
            (exclude_moment, *kinds, target_language, *params),
        )
        seen: set[str] = set()
        out: list[dict[str, Any]] = []
        for (record_json,) in rows:
            record = json.loads(record_json)
            key = record.get("source_key") or ""
            if not key or key in seen:
                continue
            seen.add(key)
            out.append(record)
            if len(out) >= limit:
                break
        return out
cwkSpark, clients/apple/SparkMobile/Sources/Views/MomentView.swift: a tap asks once, a second tap folds (verbatim, lines 298-312)·swift
    private func tap(_ line: PippaResult) async {
        if openLine == line.id {
            openLine = nil
            return
        }
        openLine = line.id
        guard let state = watcher?.state, state.explanation(of: line.id).turn == nil else { return }
        do {
            try await model.explain(momentId, resultId: line.id)
            actionError = nil
            watcher?.watch()
        } catch where !MomentWatcher.isCancellation(error) {
            actionError = error.localizedDescription
        } catch {}
    }

External links

Exercise

Add an explain action to something your app already shows as a list (search results, transcript lines, log entries). A tap sends the item's id to a route that refuses ids from another parent, starts a turn in the parent's existing conversation, and records anything the model returns with an about field pointing at the item. Ask for at most three short layers by default. If your domain has real roots or parents (word etymology, a dependency, a source document), have the model record them as typed lines and tell it, in so many words, to record none when none is real.
Hint
Make a replayed request return the turn it already started, and make a second tap on the same item fold the explanation away and a later tap reopen the existing one instead of asking again. Test the empty case on purpose: give the model an item with no real root and check nothing is recorded.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.