openvibers/openvibe.rent Commits

Rent: an AI screen for rental scam signs, flagged for staff (#50)

OP OpenVibers committed 29 days ago · cfe29f8e4e · 12 files, +407 −8

The plan's composition for Rent is Sources + Search + Watch + AI, and AI was the missing part. Rental scams are the main harm on a rentals site. Now each listing a person posts or edits is read by OpenVibe.AI's `rent.screen_listing` (AI#87) for the signs of one, and staff see what it found.

**What happens**
- A store write already names the listing it touched (the search index uses that). The screen now hears it too and reads the listing a moment later, never inside the request.
- It sends only what a renter reads (title, kind, price, place, description), never the poster or the contact link, with the listing as `target` and an idempotency key from a content hash.
- `allow` records the check. `review` puts the listing in the staff page's new **flagged** queue (`/staff?state=flagged`) with the signs in staff's words, the model's reason and the quoted words, plus **Looks fine** next to Hide and Remove.
- **Nothing changes for anyone else:** the listing stays up, nothing is refused, and a person can always post. Staff decide.

**Cost and robustness**
- The same words are never screened twice: a renewal, a report or a bedroom count doesn't cost a run.
- New words are screened again, and a new flag is unreviewed even if staff passed the old text.
- A failed run leaves no check; a sweep every 30 minutes (25 at a time) screens active listings not read since they changed.
- The output is bounded: unknown signs are dropped, `review` without a known sign becomes `allow`, at most 10 quotes of 300 characters.

**Data**
- Migration `0008_listing_checks.sql` (expand): one row per listing, cascading with it.
- It's a staff-only moderation note about a listing, not a record of the person, so it isn't in the account export. `server/identity/account-data.js` says so beside the reports.

**Configuration:** `OV_AI_INTERNAL_URL` plus the existing client secret, with Network#212 granting `rent` `ai.run.*` on `rent.*`. Unset: off, and `touch`, `start` and the sweep do nothing.

**Tests:** `test/screen.test.js` covers:
- what is and isn't sent;
- the flag, the page and its quotes;
- staff-only access (403);
- Looks fine;
- no rerun on unchanged words, a rerun on new ones;
- a failed run recovered by the sweep;
- output bounds;
- off without AI.

The README and `.env.example` document it.

Changes

ddiff --git a/.env.example b/.env.example
iindex 632d4b5..0f89adc 100644
---- a/.env.example
++++ b/.env.example
@@@ -38,6 +38,9 @@ OV_OAUTH_CLIENT_SECRET=
# are created at boot (off when unset); RENT_EVENTS_SUBSCRIBE=0 turns that off.
RENT_EVENTS_SECRET=
RENT_EVENTS_URL=
++# OpenVibe.AI for the listing screen (server/listings/screen.js): each posted or edited listing is screened for the signs of a
++# rental scam, for staff (needs OV_OAUTH_CLIENT_SECRET too). Unset: off.
++#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#RENT_EVENTS_ENDPOINT=http://127.0.0.1:5010/internal/events
# optional: defaults to true in production
ddiff --git a/README.md b/README.md
iindex 1ee8069..63f2847 100644
---- a/README.md
++++ b/README.md
@@@ -44,6 +44,8 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `
([migrations/0002_rent.sql](migrations/0002_rent.sql)).
- **`rent_reports`** — one report per person per listing (unique index `rent_reports_once`), what hides a listing at
three. `rpt_<ULID>` ids.
++- **`rent_listing_checks`** (0008) — the latest AI screen of each listing (below): the content hash it read, `allow` or
++ `review`, the signs and the quoted words, and which member of staff marked it reviewed. It goes with its listing.
- **`rent_saved_searches`** — a person's saved filter sets, one per distinct set, with `last_seen_at` for the "new
since you last looked" count. `ssv_<ULID>` ids.
- **`account_data_events`** — the receipt of each ADR-033 account export or deletion delivery Rent has applied, so a
@@@ -112,6 +114,7 @@ Index master CSV — paced and cached in [server/housing/upstream.js](server/hou
| One listing | `/listings/:id` | The listing, its safety note, and a contact link **only for a signed-in viewer** — everyone else is asked to sign in. JSON-LD is published as an `Offer` (price, currency, `priceValidUntil`) only when the kind has a clear schema.org type; `other` gets none. |
| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
| Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |
++| AI screen | [server/listings/screen.js](server/listings/screen.js), `/staff?state=flagged` | Each listing a person posts or edits is read by OpenVibe.AI's `rent.screen_listing` for the signs of a rental scam (payment before a viewing, wire/gift-card/crypto payment, an owner who cannot show the place, pressure, identity or bank details up front, only a private channel), never with the poster or the contact link. `review` puts it in the staff page's **flagged** queue with the signs and the quoted words; nothing is hidden or refused, and staff decide (**Looks fine**, hide, remove). The same words are never screened twice; a failed run is retried by a sweep every 30 minutes. Off without `OV_AI_INTERNAL_URL` (and the client secret). |
| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. |
| Alerts | `/listings.atom`, Get alerts | Every search is an Atom feed with the same filters (the newest 25, cached 5 minutes), linked from the listings page's head. **Get alerts** on the listings page and on each saved search opens OpenVibe.Watch's feed form with that feed filled in, so Watch tells you, or runs your agent, when a listing that fits appears. |
| Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads six HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities, housing counselling agencies and HUD homes for sale (`SF_REO`, HUD's March 2026 snapshot, each linked to HUD Homestore to confirm it is still for sale) — normalised, distance-sorted and grouped, with the HUD attribution on the page. |
ddiff --git a/migrations/0008_listing_checks.sql b/migrations/0008_listing_checks.sql
nnew file mode 100644
iindex 0000000..3c72b32
---- /dev/null
++++ b/migrations/0008_listing_checks.sql
@@@ -0,0 +1,18 @@
++-- phase: expand
++-- OpenVibe.Rent: the latest AI screen of each listing for the signs of a rental scam (server/listings/screen.js, the
++-- `rent.screen_listing` workflow on OpenVibe.AI). Staff only: `review` puts the listing in the staff page's flagged queue
++-- with the signs and the quoted words until a member of staff marks it reviewed (or hides or removes it). One row per
++-- listing, replaced when what the listing says changes (`content_hash`); it goes with its listing.
++CREATE TABLE IF NOT EXISTS rent_listing_checks (
++ listing_id text COLLATE "C" PRIMARY KEY REFERENCES rent_listings (id) ON DELETE CASCADE,
++ content_hash text COLLATE "C" NOT NULL, -- what a renter reads, hashed (screen.js contentHash)
++ action text COLLATE "C" NOT NULL CHECK (action IN ('allow', 'review')),
++ signals jsonb NOT NULL DEFAULT '[]'::jsonb, -- the signs named, e.g. ["payment_before_viewing"]
++ spans jsonb NOT NULL DEFAULT '[]'::jsonb, -- [{ quote, signal }] the words behind each sign
++ rationale text,
++ run_id text COLLATE "C", -- the OpenVibe.AI run that answered
++ checked_at text COLLATE "C" NOT NULL,
++ reviewed_at text COLLATE "C", -- set when staff mark a flagged listing reviewed
++ reviewed_by text COLLATE "C" -- user:usr_… of that member of staff
++);
++CREATE INDEX IF NOT EXISTS rent_listing_checks_flagged ON rent_listing_checks (checked_at DESC) WHERE action = 'review' AND reviewed_at IS NULL;
ddiff --git a/server/app.js b/server/app.js
iindex bfe1b94..c0d2060 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -19,6 +19,8 @@ const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
++const { createAiClient } = require('openvibe-publishing/ai');
++const { createScreen } = require('./listings/screen');
const cache = require('openvibe-shared/cache-policy');
const { createSearchIndex } = require('./search-index');
@@@ -93,6 +95,17 @@ async function createApp(opts = {}) {
ctx.accountData = accountData;
ctx.accountSend = accountSend;
++ // The AI listing screen (server/listings/screen.js): with OV_AI_INTERNAL_URL and a client secret, Rent asks
++ // OpenVibe.AI's rent.screen_listing about each posted or edited listing with its own service token (Network grants
++ // rent ai.run.* on rent.*); the answer is for staff. Off otherwise. A test injects a stand-in through opts.ai.
++ const aiTokenClient = config.aiUrl && config.oauth.clientSecret ? contracts.serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,
++ }) : null;
++ const ai = opts.ai !== undefined ? opts.ai : (aiTokenClient ? createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl }) : null);
++ ctx.screen = createScreen({ s, ai, log });
++ s.screen = ctx.screen;
++
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
ddiff --git a/server/config.js b/server/config.js
iindex e3e4ead..0c0d364 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -86,6 +86,9 @@ function load(env = process.env) {
// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
// unset. Both topics are ADR-033: account export and deletion (server/identity/account-data.js).
++ // OpenVibe.AI → the listing screen (server/listings/screen.js): each posted or edited listing is read by the
++ // rent.screen_listing workflow for the signs of a rental scam, for staff. Unset: off (nothing is screened).
++ aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
events: {
secrets: String(env.RENT_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
url: trim(env.RENT_EVENTS_URL || env.EVENTS_URL || ''),
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex dfcaa4b..787a84e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -785,6 +785,17 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
});
// ── The report queue (staff) ─────────────────────────────
++ // The AI screen's signs, in staff's words (server/listings/screen.js names them).
++ const SIGN_TEXT = {
++ payment_before_viewing: 'payment before a viewing',
++ unusual_payment: 'wire, gift card, crypto or a payment app',
++ owner_unavailable: 'an owner who cannot show the place',
++ pressure: 'pressure to decide or pay',
++ personal_data_request: 'identity or bank details up front',
++ off_platform_only: 'only a private channel, no calls or visits',
++ };
++ const listOf = (v) => (Array.isArray(v) ? v : (() => { try { return JSON.parse(v || '[]'); } catch { return []; } })());
++
r.get('/staff', async (req, res) => {
if (!signedIn(req)) return page(req, res, {
title: 'Report queue',
@@@ -796,9 +807,10 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
body: html`<h1>Staff only</h1><p class="muted">The report queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${truncate(req.viewer.role || 'user', 32)}</code>. <a href="/mine">Your listings</a>.</p>`,
}, 403);
-- const state = ['hidden', 'active', 'removed', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';
-- const out = await store.staffQueue(s, { state, limit: 50 });
++ const state = ['flagged', 'hidden', 'active', 'removed', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';
++ const out = state === 'flagged' ? { rows: await store.flaggedQueue(s, { limit: 50 }) } : await store.staffQueue(s, { state, limit: 50 });
const counts = await store.staffCounts(s);
++ const flagged = await store.countFlagged(s);
const items = [];
for (const row of out.rows) items.push({ row, reports: await store.reportsFor(s, row.id) });
return page(req, res, {
@@@ -807,9 +819,10 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
body: html`<h1>The report queue</h1>
<p class="sc-lede">${counts.hidden} hidden by reports, ${counts.active} reported but still active, ${counts.removed} removed. A listing is hidden automatically at ${listings.HIDE_AT_REPORTS} distinct reports.</p>
++<p class="small">${flagged ? html`<a href="/staff?state=flagged">${flagged} flagged by the AI screen</a>` : 'Nothing flagged by the AI screen.'} <span class="muted">OpenVibe.AI reads each listing a person posts or edits for the signs of a rental scam; a flag only asks for a look, and the listing stays up until you decide.</span></p>
<form class="card filters" method="get" action="/staff">
<div class="field"><label for="state">Show</label><select id="state" name="state">
--${['hidden', 'active', 'removed', 'all'].map((x) => html`<option value="${x}"${x === state ? raw(' selected') : ''}>${x === 'hidden' ? 'hidden (needs a look)' : x}</option>`)}
++${['flagged', 'hidden', 'active', 'removed', 'all'].map((x) => html`<option value="${x}"${x === state ? raw(' selected') : ''}>${x === 'hidden' ? 'hidden (needs a look)' : x === 'flagged' ? 'flagged by the AI screen' : x}</option>`)}
</select></div>
<p><button class="sc-btn sc-primary" type="submit">Filter</button> <a class="sc-btn" href="/staff">Clear</a></p>
</form>
@@@ -817,6 +830,10 @@ ${items.length ? items.map((it) => html`<section class="card staff-item">
<h2><a href="/listings/${it.row.id}">${truncate(it.row.title, 80)}</a> ${stateBadge(it.row.state)}</h2>
<p class="small muted">Posted by <code>${it.row.owner}</code> · ${whereOf(it.row)} · ${priceOf(it.row)} · ${it.row.report_count} report${Number(it.row.report_count) === 1 ? '' : 's'}</p>
${textBlock(truncate(it.row.description, 400))}
++${it.row.check_signals !== undefined ? html`<div class="screen-flag small"><p><b>The AI screen saw:</b> ${listOf(it.row.check_signals).map((x) => SIGN_TEXT[x] || x).join('; ')}</p>
++${it.row.check_rationale ? html`<p class="muted">${truncate(it.row.check_rationale, 400)}</p>` : ''}
++${listOf(it.row.check_spans).length ? html`<ul>${listOf(it.row.check_spans).map((sp) => html`<li>“${truncate(sp.quote, 200)}”${sp.signal && SIGN_TEXT[sp.signal] ? html` <span class="muted">(${SIGN_TEXT[sp.signal]})</span>` : ''}</li>`)}</ul>` : ''}
++<form class="inline" method="post" action="/listings/${it.row.id}/check"><button class="sc-btn" type="submit">Looks fine</button></form></div>` : ''}
<ul class="report-list small">${it.reports.map((rp) => html`<li><b>${rp.reason}</b> · <code>${rp.reporter}</code> · ${time(rp.created_at)}${rp.note ? html` — ${rp.note}` : ''}</li>`)}</ul>
<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="active"><button class="sc-btn" type="submit">Restore it</button></form>
<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="hidden"><button class="sc-btn" type="submit">Hide it</button></form>
@@@ -843,6 +860,19 @@ ${textBlock(truncate(it.row.description, 400))}
return res.redirect(303, '/staff');
});
++ // A member of staff looked at a listing the AI screen flagged and left it up: it leaves the flagged queue until its
++ // words change and the screen flags it again.
++ r.post('/listings/:id/check', async (req, res) => {
++ if (!signedIn(req) || !staffViewer(req)) return page(req, res, {
++ title: 'Staff only',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
++ body: html`<h1>Staff only</h1><p class="muted">Reviewing a flagged listing is for OpenVibe staff.</p>`,
++ }, signedIn(req) ? 403 : 401);
++ if (!sameSite(req, res, '/staff?state=flagged')) return;
++ if (listings.isListingId(req.params.id)) await store.markCheckReviewed(s, req.params.id, requesterOf(req));
++ return res.redirect(303, '/staff?state=flagged');
++ });
++
// ── Safety ───────────────────────────────────────────────
r.get('/safety', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 09c569e..33ad93b 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -15,7 +15,9 @@
* Nothing is anonymized: Rent keeps no row by this person that another person's page must still read. A listing is
* the poster's own; the reports other people left on it cascade away with it (rent_reports.listing_id REFERENCES
* rent_listings(id) ON DELETE CASCADE in migrations/0002_rent.sql), which is right — a report about a listing that no
-- * longer exists has nothing left to say. Nothing here is a secret: no token, key or credential is stored, so every
++ * longer exists has nothing left to say. So does the AI screen's check of a listing (rent_listing_checks, migration
++ * 0008): a staff-only moderation note about the listing, not a record of the person, gone with the listing and not
++ * exported. Nothing here is a secret: no token, key or credential is stored, so every
* column of the three tables may be exported.
*/
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
ddiff --git a/server/index.js b/server/index.js
iindex 1357195..2e221b7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -21,7 +21,7 @@ const { startHpiTimer } = require('./housing/hpi');
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
name: 'OpenVibe.Rent', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.search.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],
++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.search.stop(), () => ctx.screen.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],
});
}
@@@ -41,6 +41,7 @@ async function start() {
ctx.telemetryTick.unref();
ctx.keys.client.start();
ctx.search.start();
++ ctx.screen.start(); // the AI listing screen's sweep (off without OpenVibe.AI)
// One sweep at boot (a restart must not leave a stale listing up a moment longer than it should), then a timer.
const expiry = startExpiryTimer(ctx.s);
ddiff --git a/server/listings/screen.js b/server/listings/screen.js
nnew file mode 100644
iindex 0000000..37a8498
---- /dev/null
++++ b/server/listings/screen.js
@@@ -0,0 +1,154 @@
++'use strict';
++
++/**
++ * The listing screen (the plan's Rent composition: Sources + Search + Watch + AI): every active listing a person posts
++ * or edits is read by OpenVibe.AI's `rent.screen_listing` workflow for the signs of a rental scam — payment before a
++ * viewing, wire or gift-card payment, an owner who cannot show the place, pressure, identity documents up front, a
++ * conversation pushed off the site. The answer is for staff only: `review` puts the listing in the staff page's
++ * "flagged" queue with the signs and the quoted words; nothing is hidden, refused or shown to anyone else, and a person
++ * can always post. Staff decide (Looks fine, Hide, Remove) exactly as for a report.
++ *
++ * Every store write names the listing it touched (store.js calls s.screen.touch(id)); touched listings are screened a
++ * moment later, never inside the request. A listing is screened again only when what it says changed (a hash of the
++ * words and the price a renter reads), so a renewal or a report never costs a run. A run that fails is logged and
++ * retried by the sweep, which also covers listings posted while OpenVibe.AI was unreachable.
++ *
++ * Off without OV_AI_INTERNAL_URL and a client secret (server/app.js passes no `ai`): then touch() does nothing.
++ */
++const crypto = require('crypto');
++const store = require('./store');
++
++const FLUSH_MS = 2_000;
++const START_DELAY_MS = 90_000;
++const INTERVAL_MS = 30 * 60_000;
++const SWEEP_LIMIT = 25;
++const SIGNALS = ['payment_before_viewing', 'unusual_payment', 'owner_unavailable', 'pressure', 'personal_data_request', 'off_platform_only'];
++
++/** What a renter reads, hashed: the screen runs again only when this changes. */
++function contentHash(row) {
++ const parts = [row.kind, row.title, row.description, row.price, row.currency, row.period, row.city, row.region, row.country, row.neighbourhood];
++ return crypto.createHash('sha256').update(JSON.stringify(parts.map((v) => (v == null ? null : String(v))))).digest('hex').slice(0, 32);
++}
++
++/** The workflow's input: the listing as a renter sees it (never the poster, never the contact link). */
++function screenInput(row) {
++ return {
++ listing: {
++ title: String(row.title || '').slice(0, 200),
++ kind: String(row.kind || '').slice(0, 40),
++ price: row.price == null ? undefined : Number(row.price),
++ currency: row.currency ? String(row.currency).slice(0, 8) : undefined,
++ period: row.period ? String(row.period).slice(0, 20) : undefined,
++ city: row.city ? String(row.city).slice(0, 120) : undefined,
++ country: row.country ? String(row.country).slice(0, 8) : undefined,
++ description: String(row.description || '').slice(0, 5000),
++ },
++ };
++}
++
++/** Bound and validate the workflow's output: unknown signs are dropped; `review` without a sign is `allow`. */
++function readOutput(output) {
++ const o = output && typeof output === 'object' ? output : {};
++ const signals = [...new Set((Array.isArray(o.signals) ? o.signals : []).filter((x) => SIGNALS.includes(x)))];
++ const action = o.action === 'review' && signals.length ? 'review' : 'allow';
++ const spans = (Array.isArray(o.spans) ? o.spans : []).slice(0, 10)
++ .filter((x) => x && typeof x.quote === 'string' && x.quote.trim())
++ .map((x) => ({ quote: x.quote.trim().slice(0, 300), signal: SIGNALS.includes(x.signal) ? x.signal : null }));
++ const rationale = typeof o.rationale === 'string' ? o.rationale.trim().slice(0, 1000) : '';
++ return { action, signals: action === 'review' ? signals : [], spans: action === 'review' ? spans : [], rationale };
++}
++
++function createScreen({ s, ai = null, log = console }) {
++ const pending = new Set();
++ const timers = [];
++ let flushTimer = null;
++ let flushing = null;
++
++ /** Screen one listing now. Returns 'off' | 'gone' | 'inactive' | 'unchanged' | 'allow' | 'review' | 'failed'. */
++ async function screenId(id) {
++ if (!ai) return 'off';
++ const row = await store.get(s, id);
++ if (!row) return 'gone';
++ if (row.state !== 'active') return 'inactive';
++ const hash = contentHash(row);
++ const prev = await store.getCheck(s, id);
++ if (prev && prev.content_hash === hash) {
++ if (prev.checked_at < row.updated_at) await store.recheckUnchanged(s, id);
++ return 'unchanged';
++ }
++ let run;
++ try {
++ run = await ai.run('rent.screen_listing', screenInput(row), {
++ target: { service: 'rent', type: 'listing', id: row.id },
++ idempotencyKey: `rent-screen-${row.id}-${hash}`,
++ });
++ } catch (err) {
++ log.warn(`[Screen] listing ${id}: ${(err && err.message) || err}`);
++ return 'failed';
++ }
++ const out = readOutput(run && run.output);
++ await store.putCheck(s, { listingId: row.id, contentHash: hash, ...out, runId: (run && run.runId) || null });
++ if (out.action === 'review') log.log(`[Screen] listing ${row.id} flagged for staff: ${out.signals.join(', ')}`);
++ return out.action;
++ }
++
++ async function flush() {
++ flushTimer = null;
++ if (flushing) await flushing;
++ const ids = [...pending];
++ pending.clear();
++ flushing = (async () => {
++ for (const id of ids) {
++ try { await screenId(id); } catch (err) { log.warn(`[Screen] listing ${id}: ${(err && err.message) || err}`); }
++ }
++ })();
++ try { await flushing; } finally { flushing = null; }
++ }
++
++ /** A store write changed this listing: screen it shortly (never awaited by the request, never fatal). */
++ function touch(id) {
++ if (!ai || !id) return;
++ pending.add(String(id));
++ if (!flushTimer) {
++ flushTimer = setTimeout(() => { flush().catch(() => {}); }, FLUSH_MS);
++ if (flushTimer.unref) flushTimer.unref();
++ }
++ }
++
++ /** Active listings never screened, or changed since (a run failed, or AI was unreachable): a few at a time. */
++ async function sweep() {
++ if (!ai) return { screened: 0 };
++ const ids = await store.unscreenedActive(s, SWEEP_LIMIT);
++ let screened = 0;
++ for (const id of ids) {
++ const r = await screenId(id).catch(() => 'failed');
++ if (r === 'allow' || r === 'review') screened++;
++ }
++ return { screened, seen: ids.length };
++ }
++ const quietly = () => { sweep().catch((err) => log.warn(`[Screen] sweep failed: ${(err && err.message) || err}`)); };
++
++ function start() {
++ if (!ai || timers.length) return false;
++ const kick = setTimeout(quietly, START_DELAY_MS);
++ const tick = setInterval(quietly, INTERVAL_MS);
++ for (const t of [kick, tick]) if (t.unref) t.unref();
++ timers.push(kick, tick);
++ return true;
++ }
++
++ function stop() {
++ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }
++ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
++ }
++
++ /** Wait for touched listings to be screened (tests, and a graceful stop). */
++ async function settle() {
++ if (flushTimer) { clearTimeout(flushTimer); await flush(); }
++ if (flushing) await flushing;
++ }
++
++ return { enabled: !!ai, touch, screenId, sweep, settle, start, stop };
++}
++
++module.exports = { createScreen, contentHash, screenInput, readOutput, SIGNALS };
ddiff --git a/server/listings/store.js b/server/listings/store.js
iindex 3044435..13b952c 100644
---- a/server/listings/store.js
++++ b/server/listings/store.js
@@@ -15,8 +15,11 @@ const listings = require('./listings');
const DAY_MS = 86_400_000;
--/** A write changed this listing: OpenVibe.Search syncs it shortly (server/search-index.js; absent in some tests). */
--const touched = (s, id) => { if (s.search) s.search.touch(id); };
++/**
++ * A write changed this listing: OpenVibe.Search syncs it shortly (server/search-index.js) and the AI screen reads it
++ * again if its words changed (server/listings/screen.js); either may be absent in a test.
++ */
++const touched = (s, id) => { if (s.search) s.search.touch(id); if (s.screen) s.screen.touch(id); };
/** The WHERE clause for a filter set. `exclude` leaves one dimension out — how the kind and country facets count. */
function buildWhere(f, { exclude = [], sinceISO = null } = {}) {
@@@ -186,6 +189,42 @@ const staffCounts = async (s) => ({
removed: Number(await s.db.value("SELECT count(*) FROM rent_listings WHERE report_count > 0 AND state = 'removed'")),
});
++// ── The AI screen (server/listings/screen.js): the latest check of each listing, for staff ──
++
++const getCheck = (s, listingId) => s.db.maybe('SELECT * FROM rent_listing_checks WHERE listing_id = $1', [listingId]);
++
++/** Record a listing's latest screen. A verdict on new words is unreviewed again, whatever staff said before. */
++async function putCheck(s, { listingId, contentHash, action, signals = [], spans = [], rationale = '', runId = null }) {
++ await s.db.query(
++ `INSERT INTO rent_listing_checks (listing_id, content_hash, action, signals, spans, rationale, run_id, checked_at, reviewed_at, reviewed_by)
++ VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, NULL, NULL)
++ ON CONFLICT (listing_id) DO UPDATE SET content_hash = EXCLUDED.content_hash, action = EXCLUDED.action, signals = EXCLUDED.signals,
++ spans = EXCLUDED.spans, rationale = EXCLUDED.rationale, run_id = EXCLUDED.run_id, checked_at = EXCLUDED.checked_at,
++ reviewed_at = NULL, reviewed_by = NULL`,
++ [listingId, contentHash, action, JSON.stringify(signals), JSON.stringify(spans), rationale || null, runId, s.iso()]);
++}
++
++/** The words did not change (a renewal, a report): the check stands; only its time moves, so the sweep skips it. */
++const recheckUnchanged = (s, listingId) => s.db.exec('UPDATE rent_listing_checks SET checked_at = $2 WHERE listing_id = $1', [listingId, s.iso()]);
++
++/** Active listings the screen has not read since they last changed (a failed run, AI unreachable), oldest first. */
++const unscreenedActive = async (s, limit) => (await s.db.many(
++ `SELECT l.id FROM rent_listings l LEFT JOIN rent_listing_checks c ON c.listing_id = l.id
++ WHERE l.state = 'active' AND (c.listing_id IS NULL OR c.checked_at < l.updated_at) ORDER BY l.id LIMIT $1`, [limit])).map((r) => r.id);
++
++/** The flagged queue: listings the screen put up for review that no member of staff has looked at, newest check first. */
++const flaggedQueue = (s, { limit = 50 } = {}) => s.db.many(
++ `SELECT l.*, c.signals AS check_signals, c.spans AS check_spans, c.rationale AS check_rationale, c.checked_at AS check_checked_at
++ FROM rent_listing_checks c JOIN rent_listings l ON l.id = c.listing_id
++ WHERE c.action = 'review' AND c.reviewed_at IS NULL AND l.state <> 'removed' ORDER BY c.checked_at DESC, l.id DESC LIMIT $1`, [limit]);
++
++const countFlagged = async (s) => Number(await s.db.value(
++ "SELECT count(*) FROM rent_listing_checks c JOIN rent_listings l ON l.id = c.listing_id WHERE c.action = 'review' AND c.reviewed_at IS NULL AND l.state <> 'removed'"));
++
++/** A member of staff looked at a flagged listing and left it up. */
++const markCheckReviewed = (s, listingId, by) => s.db.exec(
++ "UPDATE rent_listing_checks SET reviewed_at = $2, reviewed_by = $3 WHERE listing_id = $1 AND action = 'review' AND reviewed_at IS NULL", [listingId, s.iso(), by]);
++
// ── Saved searches ─────────────────────────────────────────────────────────────────────────────────────────────
async function saveSearch(s, { id, subject, filters, key, createdAt }) {
@@@ -205,6 +244,7 @@ module.exports = {
insert, get, search, countMatching, facets, update, expireListings, renew, setState, remove,
countActiveByOwner, countCreatedToday, listByOwner,
reportsFor, addReport, staffQueue, staffCounts,
++ getCheck, putCheck, recheckUnchanged, unscreenedActive, flaggedQueue, countFlagged, markCheckReviewed,
saveSearch, listSaved, getSaved, deleteSaved, markSavedSeen,
DAY_MS,
};
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex fe50566..2564f01 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, indexnow: opts.indexnow, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, housingFetchImpl: opts.housingFetchImpl });
++ const built = await createApp({ config, store, log, ai: opts.ai, telemetrySink: opts.telemetrySink, indexnow: opts.indexnow, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, housingFetchImpl: opts.housingFetchImpl });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/screen.test.js b/test/screen.test.js
nnew file mode 100644
iindex 0000000..6ce1d3a
---- /dev/null
++++ b/test/screen.test.js
@@@ -0,0 +1,132 @@
++'use strict';
++/**
++ * The AI listing screen (server/listings/screen.js): each listing a person posts or edits is read by OpenVibe.AI's
++ * rent.screen_listing (a stand-in here) for the signs of a rental scam, never with the poster or the contact link; a
++ * `review` puts it in the staff page's flagged queue with the signs and the quoted words, and nothing else changes for
++ * anyone; staff mark it "Looks fine"; the same words are never screened twice, new words are; a failed run is picked
++ * up by the sweep; the output is bounded; without OpenVibe.AI nothing is screened.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { SAME, post } = require('./helpers/rent');
++const store = require('../server/listings/store');
++const { createScreen, readOutput } = require('../server/listings/screen');
++
++const SCAM = 'I am abroad for work so I cannot show it. Send the deposit by wire transfer today and I will mail you the keys.';
++
++(async () => {
++ const calls = [];
++ let failing = false;
++ const ai = {
++ run: async (key, input, opts) => {
++ calls.push({ key, input, opts });
++ if (failing) throw new Error('AI unreachable');
++ const scam = /wire transfer/.test(input.listing.description);
++ return {
++ runId: `run_${calls.length}`,
++ output: scam
++ ? { action: 'review', signals: ['owner_unavailable', 'payment_before_viewing', 'unusual_payment', 'made_up'], rationale: 'Pay by wire before any viewing; the owner is away.', spans: [{ quote: 'Send the deposit by wire transfer today', signal: 'payment_before_viewing' }] }
++ : { action: 'allow', signals: ['none'], rationale: '', spans: [] },
++ };
++ },
++ };
++ const t = await boot({ ai });
++ const s = t.ctx.s;
++ const kim = t.network.addUser('kim');
++ const ada = t.network.addUser('ada', { role: 'admin' });
++ let fine;
++ let scam;
++
++ try {
++ await check('a posted listing is screened once, as a renter reads it: never the poster or the contact link', async () => {
++ fine = (await post(t, kim)).json();
++ await t.ctx.screen.settle();
++ assert.strictEqual(calls.length, 1);
++ assert.strictEqual(calls[0].key, 'rent.screen_listing');
++ assert.strictEqual(calls[0].input.listing.title, 'Sunny two-bedroom flat near the park');
++ const sent = JSON.stringify(calls[0].input);
++ assert.ok(!sent.includes('example.com/rooms') && !sent.includes('usr_'), 'no contact link and no poster go to AI');
++ assert.deepStrictEqual(calls[0].opts.target, { service: 'rent', type: 'listing', id: fine.id });
++ assert.match(calls[0].opts.idempotencyKey, new RegExp(`^rent-screen-${fine.id}-[0-9a-f]{32}$`));
++ const c = await store.getCheck(s, fine.id);
++ assert.strictEqual(c.action, 'allow');
++ assert.deepStrictEqual(c.signals, []);
++ assert.strictEqual(await store.countFlagged(s), 0);
++ });
++
++ await check('a listing with scam signs is flagged for staff with the signs and the quoted words; it stays listed', async () => {
++ scam = (await post(t, kim, { title: 'Room in the centre', description: SCAM })).json();
++ await t.ctx.screen.settle();
++ const c = await store.getCheck(s, scam.id);
++ assert.strictEqual(c.action, 'review');
++ assert.deepStrictEqual(c.signals, ['owner_unavailable', 'payment_before_viewing', 'unusual_payment'], 'an unknown sign is dropped');
++ assert.strictEqual(await store.countFlagged(s), 1);
++ assert.strictEqual((await t.get(`/api/v1/listings/${scam.id}`)).status, 200, 'still public: a flag only asks staff to look');
++ const page = await t.get('/staff?state=flagged', { as: ada });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Room in the centre'));
++ assert.ok(page.text.includes('payment before a viewing') && page.text.includes('an owner who cannot show the place'));
++ assert.ok(page.text.includes('Send the deposit by wire transfer today'), 'the quoted words');
++ assert.ok(page.text.includes(`action="/listings/${scam.id}/check"`), 'Looks fine');
++ assert.ok(!page.text.includes('Sunny two-bedroom flat'), 'an allowed listing is not in the flagged queue');
++ assert.ok((await t.get('/staff', { as: ada })).text.includes('1 flagged by the AI screen'));
++ });
++
++ await check('only staff see the flagged queue or mark a listing reviewed', async () => {
++ assert.strictEqual((await t.get('/staff?state=flagged', { as: kim })).status, 403);
++ assert.strictEqual((await t.get(`/listings/${scam.id}/check`, { as: kim, method: 'POST', form: {}, headers: SAME })).status, 403);
++ assert.strictEqual(await store.countFlagged(s), 1);
++ });
++
++ await check('Looks fine takes it out of the queue; the same words are not screened again', async () => {
++ const r = await t.get(`/listings/${scam.id}/check`, { as: ada, method: 'POST', form: {}, headers: SAME });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(await store.countFlagged(s), 0);
++ assert.match((await store.getCheck(s, scam.id)).reviewed_by, /^user:usr_/);
++ const before = calls.length;
++ await store.update(s, scam.id, { bedrooms: 1 }); // not something the screen reads: same words
++ await t.ctx.screen.settle();
++ assert.strictEqual(calls.length, before, 'unchanged words: no run');
++ assert.strictEqual(await store.countFlagged(s), 0, 'and the review stands');
++ assert.deepStrictEqual(await store.unscreenedActive(s, 10), [], 'nor does the sweep pick it up');
++ });
++
++ await check('new words are screened again, and a new flag is unreviewed', async () => {
++ await store.update(s, scam.id, { description: `${SCAM} Also, no calls please.` });
++ await t.ctx.screen.settle();
++ assert.strictEqual(calls[calls.length - 1].input.listing.description.endsWith('no calls please.'), true);
++ assert.strictEqual(await store.countFlagged(s), 1);
++ });
++
++ await check('a failed run leaves no check; the sweep screens the listing once AI answers again', async () => {
++ failing = true;
++ const late = (await post(t, kim, { title: 'Parking space downtown', kind: 'parking', description: 'Covered space, monthly.' })).json();
++ await t.ctx.screen.settle();
++ assert.strictEqual(await store.getCheck(s, late.id), null);
++ assert.deepStrictEqual(await store.unscreenedActive(s, 10), [late.id]);
++ failing = false;
++ const out = await t.ctx.screen.sweep();
++ assert.deepStrictEqual(out, { screened: 1, seen: 1 });
++ assert.strictEqual((await store.getCheck(s, late.id)).action, 'allow');
++ });
++
++ await check('the output is bounded: review without a known sign is allow; quotes are trimmed and capped', () => {
++ assert.deepStrictEqual(readOutput({ action: 'review', signals: ['none'] }), { action: 'allow', signals: [], spans: [], rationale: '' });
++ assert.strictEqual(readOutput(null).action, 'allow');
++ const many = readOutput({ action: 'review', signals: ['pressure'], spans: Array.from({ length: 30 }, () => ({ quote: ` ${'x'.repeat(400)} `, signal: 'nope' })) });
++ assert.strictEqual(many.spans.length, 10);
++ assert.strictEqual(many.spans[0].quote.length, 300);
++ assert.strictEqual(many.spans[0].signal, null);
++ });
++
++ await check('without OpenVibe.AI nothing is screened and nothing starts', async () => {
++ const off = createScreen({ s, ai: null });
++ off.touch(fine.id);
++ assert.strictEqual(off.enabled, false);
++ assert.strictEqual(off.start(), false);
++ assert.deepStrictEqual(await off.sweep(), { screened: 0 });
++ assert.strictEqual(await off.screenId(fine.id), 'off');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });