openvibers/openvibe.ai Commits

AI: rent.screen_listing, a rental listing's scam signs for Rent's staff (#87)

OP OpenVibers committed 29 days ago · e08304c831 · 4 files, +24 −5

The plan's composition for Rent is Sources + Search + Watch + AI; the AI part is a screen of each posted listing for the signs of a rental scam, for Rent's staff (OpenVibe.Rent follows).

- **`rent.screen_listing`** (namespace `rent`, route `rent.screen`, `classify` operation, private cache):
  - **Input:** the listing's title, kind, price, currency, period, city, country and description.
  - **Output:** `signals` (`payment_before_viewing`, `unusual_payment`, `owner_unavailable`, `pressure`, `personal_data_request`, `off_platform_only`, `none`), `action` (`allow` or `review`), a `rationale` and the quoted `spans`.
- **The prompt names only concrete patterns.** It says outright that a low price alone is not a sign (the model can't know the area), that ordinary rules are not signs (references, a deposit at signing, proof of income), and that `allow` is the default unless the words show a sign.
- **Route:** `rent.screen` is seeded like the other historical routes, so it runs on the default JSON route (today the free local model).
- **Tests and docs:** `test/workflows.test.js` runs the workflow end to end on the stub and checks the workflow and route are seeded. The README lists it, and `food.suggest_recipes`, which was missing from the list.

Changes

ddiff --git a/README.md b/README.md
iindex e7b8d06..1f98bf8 100644
---- a/README.md
++++ b/README.md
@@@ -368,7 +368,8 @@ the service boots without Valkey; with it on, production refuses to boot without
- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page`, `blog.draft_post`,
`news.summarize_story`, `news.compare_perspectives`, `reviews.summarize_entity`,
`deals.enrich_deal`, `coupons.extract_coupon`, `trade.summarize_market_context`,
-- `codes.generate_docs`, `games.generate_lore`, `moderation.classify`. They take numbered
++ `codes.generate_docs`, `games.generate_lore`, `moderation.classify`, `food.suggest_recipes`,
++ `rent.screen_listing` (a rental listing's scam signs for Rent's staff: `allow` or `review`, quoting the words). They take numbered
`sources`, return `citations` (indices the engine bounds to the sources given) and `gaps`, and
never invent facts: Reviews has no rating field, a Deals price is null unless cited, a Coupons
expiry stays unknown unless stated, Trade carries its informational-only disclaimer. The
ddiff --git a/server/workflows/products.js b/server/workflows/products.js
iindex 4b204d1..172e89e 100644
---- a/server/workflows/products.js
++++ b/server/workflows/products.js
@@@ -57,6 +57,8 @@ const templates = [
'Tool: {{tool}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
productTemplate('food.suggest_recipes', 'Food: suggest recipes', 'Suggest up to three simple, low-cost recipes the person can cook, built first from their pantry. An ingredient from the pantry has from_pantry true and needs no citation. Every other ingredient must be one of the foods in the numbered sources (OpenVibe.Food\'s food list) and cite it; if a recipe needs something the sources do not list, leave that recipe out and name the missing food in gaps. State amounts in everyday units. Never state prices, calories, nutrition or health claims: the app works out costs from its own data. Keep steps short and safe (cook meat, poultry and eggs through).', 'food.suggest',
'Pantry: {{json pantry}}{{#people}}\nCooking for: {{people}}{{/people}}{{#ask}}\nRequest: {{ask}}{{/ask}}\n\n'),
++ productTemplate('rent.screen_listing', 'Rent: screen a listing for scam signs', 'Read one rental listing a person posted and say whether a member of staff should look at it for the signs of a rental scam. The signs: asking for a deposit, rent or any payment before the place can be viewed (payment_before_viewing); asking to pay by wire transfer, gift cards, cryptocurrency or a payment app sent to a stranger (unusual_payment); an owner who is abroad or otherwise cannot show the place in person (owner_unavailable); pressure to decide or pay at once, such as many other applicants or today only (pressure); asking for identity documents, a social security number or bank details before any viewing (personal_data_request); moving the conversation to a private email or messaging app while refusing calls or visits (off_platform_only). A low price alone is not a sign: you cannot know the area. Ordinary rules (no pets, references, a deposit paid at signing, proof of income at application) are not signs. Quote the exact words behind each sign you name. When no sign is clearly present, answer allow with signals none; choose review only when the words show at least one sign.', 'rent.screen',
++ 'Listing:\n"""\n{{json listing}}\n"""\n\n'),
productTemplate('moderation.classify', 'Moderation: classify content', 'Classify the content under OpenVibe policy. Profanity, insults, crude jokes and trash talk are ALLOWED and are not violations. Flag only: credible threats of violence, any sexual content involving minors, doxxing (sharing private personal information), targeted harassment campaigns, spam/scams, sexual content where not allowed, and self-harm encouragement. Quote the exact spans you flag. When unsure, choose review, not block.', 'moderation.classify',
'{{#context}}Context: {{context}}\n\n{{/context}}Content to classify:\n"""\n{{text}}\n"""\n\n'),
];
@@@ -171,6 +173,21 @@ const workflows = [
}),
default_route: 'food.suggest',
},
++ {
++ key: 'rent.screen_listing', name: 'Screen a rental listing for scam signs', namespace: 'rent',
++ input_schema: OBJ({
++ listing: OBJ({ title: STR(200), kind: STR(40), price: { type: 'number' }, currency: STR(8), period: STR(20), city: STR(120), country: STR(8), description: STR(5000) }, ['title']),
++ sources: SOURCES(0),
++ }, ['listing']),
++ output_schema: OBJ({
++ signals: { type: 'array', maxItems: 7, items: { enum: ['payment_before_viewing', 'unusual_payment', 'owner_unavailable', 'pressure', 'personal_data_request', 'off_platform_only', 'none'] } },
++ action: { enum: ['allow', 'review'] },
++ rationale: STR(1000),
++ spans: ARR(OBJ({ quote: STR(500), signal: STR(40) }), 20),
++ citations: CITES,
++ }),
++ default_route: 'rent.screen', cache_mode: 'private',
++ },
{
key: 'moderation.classify', name: 'Classify content for moderation', namespace: 'moderation',
input_schema: OBJ({ text: STR(8000), context: STR(1000), sources: SOURCES(0) }, ['text']),
@@@ -187,7 +204,7 @@ const workflows = [
].map(w => ({
cache_mode: 'private',
...w,
-- steps: [{ kind: 'llm', template: w.key, operation: w.key === 'moderation.classify' ? 'classify' : 'generate', output: 'json', prepare: 'sources', cite: true, params: { max_tokens: 2400, temperature: 0.3 } }],
++ steps: [{ kind: 'llm', template: w.key, operation: w.key === 'moderation.classify' || w.key === 'rent.screen_listing' ? 'classify' : 'generate', output: 'json', prepare: 'sources', cite: true, params: { max_tokens: 2400, temperature: 0.3 } }],
metadata: { draft: true, seed: true },
}));
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 0319941..58976cf 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -21,7 +21,7 @@ const products = require('./products');
const { resolveSecret } = require('../util');
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
--const HISTORICAL_ROUTES = ['wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest'];
++const HISTORICAL_ROUTES = ['wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest', 'rent.screen'];
function sharedProviderRecord(config, env) {
const s = config.shared;
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex eae5bcb..37f1697 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -46,6 +46,7 @@ const EXAMPLES = {
'codes.generate_docs': { project: 'demo', sources: [{ source_type: 'code.file', title: 'index.js', content: 'module.exports = 1;' }] },
'games.generate_lore': { world: 'Hobo Quest', prompt: 'The origin of the tin-can knights', sources: SRC(1) },
'moderation.classify': { text: 'you absolute potato, gg', context: 'arena chat' },
++ 'rent.screen_listing': { listing: { title: 'Bright room near the river', kind: 'room', price: 650, currency: 'EUR', period: 'month', city: 'Porto', country: 'PT', description: 'I am abroad for work; send the deposit by wire and I will mail the keys.' } },
'food.suggest_recipes': { pantry: ['rice', 'onions', 'canned tomatoes'], people: 2, sources: [{ source_type: 'food.food', source_id: 'lentils', title: 'Lentils, dried', content: '{"id":"lentils","unit":"kg"}' }] },
'tools.describe': { tool: 'img.convert', sources: [{ source_type: 'tools.tool', title: 'img.convert descriptor', content: '{"id":"img.convert","inputs":["file","format"]}' }] },
};
@@@ -91,11 +92,11 @@ t.test('boot seeds every required workflow key', async () => {
h = await boot({ env: { OV_MEDIA_INTERNAL_URL: mediaBase, AI_IMAGE_MODEL: 'gpt-image-1' } });
const keys = (await h.registry.listWorkflows()).map(w => w.key);
for (const k of ['wiki.generate_space', 'wiki.generate_page', 'blog.draft_post', 'news.summarize_story', 'news.compare_perspectives', 'reviews.summarize_entity', 'deals.enrich_deal',
-- 'coupons.extract_coupon', 'trade.summarize_market_context', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest_recipes',
++ 'coupons.extract_coupon', 'trade.summarize_market_context', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest_recipes', 'rent.screen_listing',
'live.translate', 'live.stream.describe_frame', 'live.stream.summarize', 'live.viewers.reply', 'live.chat.insight', 'live.hero.slogans', 'live.stream.recap', 'network.site_copy']) {
assert.ok(keys.includes(k), `missing ${k}`);
}
-- for (const k of ['default.chat', 'default.json', 'default.embedding', 'default.image', 'wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'tools.describe', 'moderation.classify', 'food.suggest']) {
++ for (const k of ['default.chat', 'default.json', 'default.embedding', 'default.image', 'wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'tools.describe', 'moderation.classify', 'food.suggest', 'rent.screen']) {
assert.ok(await h.registry.getRoute(k), `missing historical route ${k}`);
}
});