AI language checking and moderation notes.
Internal handoff for engineers and AI agents. Product backlog lives in LOLA-FEATURE-PLAN.md.
Admin UI does not live in this repo. Lola is the public feed. Review, unblock, reports, and dictionary cleanup go in the centralized Tabula dashboard (NEXT_PUBLIC_TABULA_SITE_URL, default tabula.kifuliiru.com). Same Supabase project.
Lola is Kifuliiru-first. English, French, and Kiswahili are translations, not replacements.
On publish we may:
Kifuliiru: …) into columns.OpenAI is a language helper, not an author. Public copy already says post text may be sent for checks; DMs are not.
| Concern | Where it lives | |--------|----------------| | Composer + feed | lola-kifuliiru-web | | OpenAI calls | Server routes only (OPENAI_API_KEY) | | Guard / strikes / blocks | lola_user_ai_guard + lola_ai_guard_touch() | | Mini Dictionary words | lola_kifuliiru_mini_dictionary | | Post text + locale columns | feed_activities | | Human translations | feed_translations | | Reports, unblock, queues | Tabula dashboard (to build) |
Set these on the Lola host (Vercel / local). Never put the OpenAI key in NEXT_PUBLIC_*.
| Variable | Required | Role | |----------|----------|------| | NEXT_PUBLIC_SUPABASE_URL | Yes | Same project as Tabula | | NEXT_PUBLIC_SUPABASE_ANON_KEY | Yes | Browser + route-handler session | | OPENAI_API_KEY | Yes for AI | Language check, background translations, harvest | | NEXT_PUBLIC_TABULA_SITE_URL | Recommended | Dashboard links (default https://tabula.kifuliiru.com) | | SUPABASE_SERVICE_ROLE_KEY | Tabula / admin APIs | Not used by the Lola AI routes today |
Without OPENAI_API_KEY:
/api/lola/complete-post-translations returns 503.{ added: 0 }.These objects exist on the Kifuliiru Supabase project:
lola_kifuliiru_mini_dictionarylola_user_ai_guardlola_ai_guard_touch(p_kind text, p_junk boolean)lola_reject_blocked_poster on feed_activities before insertfeed_activities already has content, content_kifuliiru, content_english, content_french, content_kiswahili, and metadata.postLocale.
/mini-dictionary.Hi everyone!! How are you today??
Kifuliiru: Musingo ku mweehsi!! Kuti myazi yinyu zeene??
```text
Feed shows the Kifuliiru line. English is stored. French and Kiswahili fill after publish if the key is set.
5. Post `kkdhdhdhdhsjsjs` three times in a day → fourth attempt is paused (composer toast + insert trigger).
6. Open Tabula `/overview` from the Lola sidebar. That is the home for admin work, not a new Lola `/admin` app.
---
## Product rules (do not drift)
- Default post language is **Kifuliiru**. Selector: Kifuliiru, Kiswahili, Français, English. No “auto” / “other” in the composer. Old `'other'` tags still read.
- **Block** (not warn) when the tag is Kifuliiru and the text is mostly EN/FR/SW, or mixed with no real Kifuliiru translation.
- User can change the text, change the tag, or “Add this as an X translation.”
- Feed shows **main language text**. Extra languages show as a small mark (`EN`, `FR`, `SW`). Click opens the translation dialog.
- Sparkles in that dialog **draft** missing fields. Save persists. No second AI check on Save.
- Gibberish is **not** classified with OpenAI. First posts may still publish. Repeat junk pauses the account.
- Mini Dictionary stores **Kifuliiru-only** words. Never EN/FR/SW.
- If the post has Kifuliiru **plus** one of EN/FR/SW, missing languages are filled **after publish** from the supported source, aligned with the Kifuliiru.
- **Labeled lines are translations.** `Kifuliiru:`, `English:`, `Français:` / `French:`, and `Kiswahili:` / `Swahili:` mark the language of the text that follows. Users can write a bilingual post in **one box**; we split on Post. Tell them this in the composer, Guide, FAQ, and About — do not hide it behind Add translation only.
---
## Data model
### `feed_activities` locale fields
| Column / field | Role | |----------------|------| | `content` | Main feed text (Kifuliiru when that is the tag) | | `content_kifuliiru` | Kifuliiru only — never the mixed English+Kifuliiru blob | | `content_english` / `content_french` / `content_kiswahili` | Translations | | `metadata.postLocale` | `kifuliiru` \| `sw` \| `fr` \| `en` |
Helpers: `src/lib/lola-post-translations.ts` (`buildActivityLocalePatch`, `getMainPostText`, `canCompleteRemainingTranslations`).
### `lola_kifuliiru_mini_dictionary`
| Column | Role | |--------|------| | `word` | Unique, lowercase Kifuliiru token | | `source_activity_id` | Post that produced it (or the parent post of a comment) | | `source_comment_id` | Comment that produced it, when the source is `feed_comments` | | `created_by` | Poster or commenter | | `created_at` | When saved |
RLS: public select; authenticated insert own row. Page `/mini-dictionary` reads **only** this table (`src/services/feed-dictionary-service.ts`).
### `lola_user_ai_guard`
One row per user. Clients may **select** their own row (and admins may select all). Inserts/updates go through the SECURITY DEFINER RPC only.
| Column | Role | |--------|------| | `user_id` | PK, `auth.users` | | `junk_strikes` | Junk posts in the current 24h window | | `last_junk_at` | Last junk; window resets after 24h | | `ai_window_started_at` / `ai_calls_in_window` | Language check + translate: **12 / 10 minutes** | | `harvest_window_started_at` / `harvest_calls_in_window` | Harvest: **2 / hour** | | `ai_blocked_until` | No OpenAI until this time | | `posting_blocked_until` | No new `feed_activities` insert until this time |
RPC `lola_ai_guard_touch(p_kind, p_junk)`:
| `p_kind` | Effect | |----------|--------| | `language_check` | Count an AI call, or a junk strike if `p_junk` | | `translate` | Count an AI call | | `harvest` | Count a harvest call | | `status` | Read-only block state |
Hard block reasons: `posting_blocked`, `ai_blocked`, `junk_blocked`. `rate_limited` skips OpenAI but still allows a real post.
**Junk policy:** 3 junk posts in 24 hours → `ai_blocked_until` and `posting_blocked_until` = now + 24 hours.
**Unblock (Tabula):** set both `*_until` columns to `null` and `junk_strikes` to `0`. The insert trigger reads `posting_blocked_until` only.
### Reports (not built yet)
`PostCard` Report is a toast only (`lola.post_report_thanks`). Planned table for Tabula — create once, use from Lola write + Tabula read:
```sql
create table public.lola_content_reports (
id uuid primary key default gen_random_uuid(),
reporter_id uuid not null references auth.users (id) on delete cascade,
activity_id uuid references public.feed_activities (id) on delete set null,
target_user_id uuid references auth.users (id) on delete set null,
reason text not null, -- spam | hate | junk | other
details text,
status text not null default 'open', -- open | reviewing | resolved | dismissed
resolved_by uuid references auth.users (id),
resolved_at timestamptz,
created_at timestamptz not null default now()
);
```text
Suggested RLS: reporter inserts own row; `admin` / `super_admin` (and Tabula service role) select/update. Do **not** add a Lola admin page for this.
---
## Lola code map
| Path | Role | |------|------| | `src/lib/post-locale.ts` | Allowed locales, default Kifuliiru | | `src/lib/lola-post-language.ts` | Gate, segments, labeled-box parse, client `checkPostLanguage` | | `src/lib/lola-openai-post-language.ts` | `gpt-4o-mini` JSON: check, harvest, complete translations | | `src/lib/lola-post-translations.ts` | Column map, patch, “can complete remaining” | | `src/lib/lola-post-junk.ts` | Local keyboard-smash detector (no tokens) | | `src/lib/lola-ai-guard.ts` | RPC wrapper + `PostLanguageGuardError` | | `src/components/feed/CreatePost.tsx` | Check → split → post; guard toast | | `src/app/(lola)/FeedPageClient.tsx` | `createActivity`, background complete | | `src/components/feed/TranslationModal.tsx` | Sparkles = preview; Save persists | | `src/components/feed/PostCard.tsx` | Main text + extra-language mark; Report stub | | `src/app/api/lola/check-post-language/route.ts` | Auth + guard + OpenAI check | | `src/app/api/lola/complete-post-translations/route.ts` | Auth + guard + fill missing langs | | `src/app/api/lola/mini-dictionary/harvest/route.ts` | Auth + guard + extract words from recent Kifuliiru posts |
Wemp tasks: `post-lang-ai-001`, `post-translations-001`, `post-ai-guard-001`. Reporting: `safety-report-001`. Tabula queue: `safety-mod-queue-001`.
---
## Runtime flows
### 1. Language check (on Post)
```text
CreatePost
→ POST /api/lola/check-post-language { text, postLocale }
→ looksLikeKeyboardJunk? (local)
→ lola_ai_guard_touch('language_check', junk)
→ if hard block: 429
→ if junk or rate_limited: allow, no OpenAI, no dictionary words
→ else gpt-4o-mini JSON
→ if mixed but segments have Kifuliiru + EN/FR/SW: allow and split
→ content + content_kifuliiru = Kifuliiru slice only
→ extracted EN/FR/SW → those columns
```text
Gate (`decidePostLanguageGate`):
- Kifuliiru + split pair → allow.
- Mixed / `detected === mixed` → block (`mixed`).
- Tag Kifuliiru + detected EN/FR/SW, or `foreignRatio >= 0.25` → block (`kifuliiru_foreign`).
- Other tag vs other detected language → block (`mismatch`).
- Detected `other` on a Kifuliiru tag → **allow** (unknown real Kifuliiru must not be blocked).
- Under 3 word tokens → skip the model.
OpenAI outage: fail-open so people can still post.
### 2. One-box bilingual split
Example:
```text
Hi everyone!! How are you today??
Kifuliiru: Musingo ku mweehsi!! Kuti myazi yinyu zeene??
```text
- Feed / `content_kifuliiru`: `Musingo ku mweehsi!! Kuti myazi yinyu zeene??`
- `content_english`: `Hi everyone!! How are you today??`
- French + Kiswahili: background complete from English
- Dictionary: Kifuliiru words only
Labeled lines (`Kifuliiru:`, `English:`, `Français:`, `Kiswahili:`) are parsed locally. Unlabeled text before a `Kifuliiru:` line is treated as English unless the model assigns French or Kiswahili. `+ Add translation` is optional — the one-box labeled pattern is the easier path and should be taught first.
**Where users see this**
| Place | Key / file | |-------|------------| | Composer (under language picker) | `lola.composer_label_split_hint` | | Mixed-language block | `lola.composer_lang_blocked_mixed` | | Guide, FAQ, About | `lola-guide-sections.ts`, `lola.faq.languages`, `lola.faq.ai`, `lola.page.about.p8` | | This handoff | this section |
### 3. Background translations
After insert, if Kifuliiru + one supported language and the main text is not junk:
```text
POST /api/lola/complete-post-translations { activityId }
```text
Writes `feed_translations` and missing `content_*` columns. `preview: true` (translation dialog sparkles) returns drafts only.
Source preference: English, then French, then Kiswahili.
### 4. Mini Dictionary
- After an allowed Kifuliiru post, `kifuliiruWords` are upserted.
- After a new comment, `POST /api/lola/mini-dictionary/from-comment` extracts Kifuliiru-only words and stores `source_comment_id` plus the parent `source_activity_id`. Short or junk comments are skipped.
- Signed-in visit to `/mini-dictionary` may `POST /api/lola/mini-dictionary/harvest` (recent unharvested Kifuliiru posts **and** comments). Rate-limited. Junk texts skipped.
### 5. Junk and token guard
`looksLikeKeyboardJunk` is conservative (length, vowel ratio, unique-letter ratio). Real Kifuliiru is not treated as smash.
| Event | Tokens? | Post? | |-------|---------|-------| | First junk post | No | Yes (strike 1) | | Second junk | No | Yes (strike 2) | | Third junk | No | No — 24h pause | | 13th language check in 10 min | No | Yes (skip AI) | | 3rd harvest in an hour | No | n/a |
The trigger blocks insert even if someone bypasses the composer.
---
## Reporting (Lola write, Tabula read)
**Today:** menu → toast. Nothing is stored.
**Build it this way so admin does not scatter:**
1. Add `lola_content_reports` on the shared project (SQL above).
2. In Lola `PostCard.handleReport`, `insert` a row (reason picker optional). Keep the thank-you toast.
3. In **Tabula**, add a **Lola** section (not a new Lola admin app):
- Open reports
- Link to the Lola post URL
- Resolve / dismiss
- Optional: hide post (`is_public = false`) or pause the author via `lola_user_ai_guard`
Do not build `/admin`, `/moderation`, or a second queue in `lola-kifuliiru-web`.
---
## Tabula dashboard (centralized)
Lola already opens Tabula from the sidebar (`${tabulaBase}/overview`). Put all quality/admin work there.
Suggested **Lola** area in Tabula (roles: `admin`, `super_admin`; editors may see reports only):
### 1. AI guard
- Table: `lola_user_ai_guard` joined to `profiles` (name, username, email).
- Filters: `posting_blocked_until > now()`, `ai_blocked_until > now()`, `junk_strikes > 0`.
- Actions:
- **Unblock** — clear both `*_until`, set `junk_strikes = 0`.
- **Pause 24h / 7d** — set both `*_until`.
- Show `ai_calls_in_window` so a token spike is visible.
Admins can already `select` this table. Writes should use a Tabula API with the service role, or a small SECURITY DEFINER `lola_ai_guard_admin_set(...)`. Do not open client updates on the table.
### 2. Reports queue
- Table: `lola_content_reports` (once created).
- Open → reviewing → resolved / dismissed.
- Deep link: `https://lola.kifuliiru.com` + post permalink.
### 3. Mini Dictionary
- Table: `lola_kifuliiru_mini_dictionary`.
- Delete bad tokens (EN/FR/SW that slipped in, names, smash).
- Optional: hide a word without deleting.
Lola `/mini-dictionary` stays a public reader.
### 4. Post locale / translations (optional)
- Recent `feed_activities` with empty `content_kifuliiru` or mixed-looking `content`.
- Approve `feed_translations.is_approved` (helper already exists in `translation-service.ts`; no Lola UI).
### 5. What stays out of Tabula
- Composer, language gate UX, sparkles, feed marks — Lola only.
- OpenAI keys — Lola server env only. Tabula should not call OpenAI for the same checks unless you later share a small internal API.
### Tabula routes (proposal)
Keep them under one namespace so they do not scatter:
| Tabula path | Data | |-------------|------| | `/overview` | Existing home; card linking to Lola tools | | `/lola/guard` | `lola_user_ai_guard` | | `/lola/reports` | `lola_content_reports` | | `/lola/dictionary` | Mini Dictionary cleanup |
Lola only needs the existing `NEXT_PUBLIC_TABULA_SITE_URL` plus, later, optional path env vars if those routes move.
---
## Operational notes
- Model: `gpt-4o-mini`, `temperature: 0`, JSON object. Check prompt asks for `detectedLanguage`, `isMixed`, `foreignRatio`, `kifuliiruWords`, `segments`.
- Cost levers: junk skip, 12 calls / 10 min, harvest 2 / hour, no second check on Save, no OpenAI for < 3 words.
- If the RPC is down, Lola **fail-opens** the guard (logs the error) but still skips the model for local junk.
- Feed status filter (pending / approved / rejected) in Lola is UI-only today. If you turn that into a real queue, put the **actions** in Tabula and keep Lola as the reader.
---
## Follow-ups
| ID | Work | Where | |----|------|--------| | `safety-report-001` | Report insert + reason | Lola write, shared table | | `safety-mod-queue-001` | Open / resolve reports | **Tabula** `/lola/reports` | | `post-ai-guard-001` | Unblock + pause UI | **Tabula** `/lola/guard` | | — | Mini Dictionary delete | **Tabula** `/lola/dictionary` | | — | Longer ban after a second wave | Guard RPC + Tabula action |