recherche

This commit is contained in:
Bastien COIGNOUX
2026-05-04 22:11:46 +02:00
parent 2b8741de08
commit 360522f30a
10 changed files with 1137 additions and 4 deletions

View File

@ -0,0 +1,333 @@
/// <reference path="../pb_data/types.d.ts" />
const ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
function getAnthropicKey() {
return $os.getenv("ANTHROPIC_API_KEY");
}
/**
* @param {string} userText
* @returns {{ text: string, error?: { status: number, body: unknown } }}
*/
function callAnthropic(userText) {
const key = getAnthropicKey();
if (!key) {
return { text: "", error: { status: 500, body: { message: "ANTHROPIC_API_KEY manquante" } } };
}
const payload = {
model: ANTHROPIC_MODEL,
max_tokens: 2200,
messages: [{ role: "user", content: userText }],
};
const res = $http.send({
url: "https://api.anthropic.com/v1/messages",
method: "POST",
headers: {
"x-api-key": key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify(payload),
timeout: 120,
});
if (res.statusCode >= 400) {
const errText = typeof res.raw === "string" ? res.raw : "";
return {
text: "",
error: {
status: 502,
body: { message: "Anthropic", statusCode: res.statusCode, detail: errText.slice(0, 1500) },
},
};
}
let parsed = res.json;
if (parsed == null && typeof res.raw === "string" && res.raw.length > 0) {
try {
parsed = JSON.parse(res.raw);
} catch (_) {
parsed = null;
}
}
const text =
parsed && Array.isArray(parsed.content) && parsed.content[0] && parsed.content[0].text
? parsed.content[0].text
: "";
return { text };
}
function readJsonBody(e) {
const b = e.requestInfo().body || {};
return typeof b === "object" && b != null ? b : {};
}
routerAdd(
"POST",
"/api/mdb/agent-immobilier",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const objectif = typeof body.objectif === "string" ? body.objectif : "prospection off-market";
const contexte = typeof body.contexte === "string" ? body.contexte : "";
const prompt =
"Tu es un agent immobilier senior en France. Rédige un plan d'actions concret (puces) puis un brouillon de message court (email ou message) pour : " +
objectif +
".\nContexte fourni par l'utilisateur :\n" +
contexte;
const { text, error } = callAnthropic(prompt);
if (error) {
return e.json(error.status, error.body);
}
const save = body.save === true;
if (save && text) {
const rec = new Record($app.findCollectionByNameOrId("courriers_immobilier"), {
user: e.auth.id,
titre: "Brouillon — " + objectif.slice(0, 80),
corps: text,
kind: "prospection",
etat: "brouillon",
});
$app.save(rec);
return e.json(200, { brouillon: text, courrier_id: rec.id });
}
return e.json(200, { brouillon: text });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-marchand",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const titre = typeof body.titre === "string" ? body.titre : "Annonce";
const prix = typeof body.prix === "number" ? body.prix : null;
const surface = typeof body.surface === "number" ? body.surface : null;
const code_postal = typeof body.code_postal === "string" ? body.code_postal : "";
const ville = typeof body.ville === "string" ? body.ville : "";
const notes = typeof body.notes === "string" ? body.notes : "";
const grille = typeof body.grille_json === "string" ? body.grille_json : "";
const prompt =
"Tu es un marchand de biens en France. Analyse l'offre suivante : titre=" +
titre +
", prix=" +
String(prix) +
", surface_m2=" +
String(surface) +
", CP=" +
code_postal +
", ville=" +
ville +
".\nNotes utilisateur : " +
notes +
"\nRéférentiel grille perso (JSON optionnel) : " +
grille +
"\nRéponds en français : (1) fourchette €/m² si calculable, (2) points de vigilance, (3) verdict rapide opportunité / neutre / risqué.";
const { text, error } = callAnthropic(prompt);
if (error) {
return e.json(error.status, error.body);
}
return e.json(200, { analyse: text });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-dvf",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const libelle = typeof body.libelle === "string" ? body.libelle : "";
if (!libelle.trim()) {
return e.json(400, { message: "libelle requis" });
}
const code_insee = typeof body.code_insee === "string" ? body.code_insee : "";
const annee = typeof body.annee === "number" ? body.annee : null;
const prix_m2_median = typeof body.prix_m2_median === "number" ? body.prix_m2_median : null;
const nb_ventes = typeof body.nb_ventes === "number" ? body.nb_ventes : null;
const detail_json = typeof body.detail_json === "string" ? body.detail_json : "";
const col = $app.findCollectionByNameOrId("transactions_secteur");
const data = {
user: e.auth.id,
libelle: libelle.trim(),
source: "manuel",
};
if (code_insee) {
data.code_insee = code_insee;
}
if (annee != null) {
data.annee = annee;
}
if (prix_m2_median != null) {
data.prix_m2_median = prix_m2_median;
}
if (nb_ventes != null) {
data.nb_ventes = nb_ventes;
}
if (detail_json) {
data.detail_json = detail_json;
}
const rec = new Record(col, data);
$app.save(rec);
const prompt =
"Tu es un analyste immobilier. Synthétise en 5 phrases maximum l'intérêt de ces statistiques de marché (secteur, médiane €/m², volume) pour un marchand de biens.\nDonnées : " +
JSON.stringify({
libelle: libelle.trim(),
code_insee,
annee,
prix_m2_median,
nb_ventes,
});
const { text, error } = callAnthropic(prompt);
if (error) {
return e.json(error.status, error.body);
}
return e.json(200, { id: rec.id, synthese: text });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-veille",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const titre = typeof body.titre === "string" ? body.titre.trim() : "";
if (!titre) {
return e.json(400, { message: "titre requis" });
}
const url = typeof body.url === "string" ? body.url.trim() : "";
const source = typeof body.source === "string" ? body.source.trim() : "manuel";
const prix = typeof body.prix === "number" ? body.prix : undefined;
const surface = typeof body.surface === "number" ? body.surface : undefined;
const code_postal = typeof body.code_postal === "string" ? body.code_postal : "";
const ville = typeof body.ville === "string" ? body.ville : "";
const empreinte = $security.md5((url || "") + "\n" + titre);
const col = $app.findCollectionByNameOrId("annonces_veille");
const filt = 'user = "' + e.auth.id + '" && empreinte = "' + empreinte + '"';
let existing = null;
try {
existing = $app.findFirstRecordByFilter("annonces_veille", filt);
} catch (_) {
existing = null;
}
if (existing != null && existing.id) {
return e.json(200, { id: existing.id, dedupe: true, message: "Déjà enregistrée" });
}
const ann = {
user: e.auth.id,
titre,
url,
source,
empreinte,
statut: "nouveau",
};
if (prix !== undefined) {
ann.prix = prix;
}
if (surface !== undefined) {
ann.surface = surface;
}
if (code_postal) {
ann.code_postal = code_postal;
}
if (ville) {
ann.ville = ville;
}
const rec = new Record(col, ann);
$app.save(rec);
return e.json(200, { id: rec.id, dedupe: false });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-redaction",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const kind = typeof body.kind === "string" ? body.kind : "annonce_agence";
const bullets = Array.isArray(body.bullets) ? body.bullets.map(String).join("\n- ") : "";
const prompt =
"Tu es rédacteur pour une agence immobilière en France. Rédige un texte court et professionnel (titres + paragraphes) à partir des puces :\n- " +
bullets +
"\nType de contenu : " +
kind +
".";
const { text, error } = callAnthropic(prompt);
if (error) {
return e.json(error.status, error.body);
}
const save = body.save === true;
if (save && text) {
const col = $app.findCollectionByNameOrId("courriers_immobilier");
const rec = new Record(col, {
user: e.auth.id,
titre: "Rédaction — " + kind,
corps: text,
kind: kind === "relance" || kind === "prospection" ? kind : "annonce_agence",
etat: "pret",
});
$app.save(rec);
return e.json(200, { texte: text, courrier_id: rec.id });
}
return e.json(200, { texte: text });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-alertes-scan",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const now = new Date().toISOString();
let updated = 0;
try {
const rows = $app.findRecordsByFilter(
"alertes_recherche",
'user = "' + e.auth.id + '"',
"-created",
50,
0,
);
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (!r) {
continue;
}
if (r.get("actif") === false) {
continue;
}
r.set("derniere_verification", now);
r.set("dernier_nb_resultats", 0);
$app.save(r);
updated++;
}
} catch (_) {
/* noop */
}
return e.json(200, { processed: updated, note: "Stub: branchement annonces agrégées à venir." });
},
$apis.requireAuth(),
);
cronAdd("mdb_agents_alertes_tick", "0 * * * *", () => {
/* Placeholder : futur batch serveur (sans auth utilisateur). */
});

View File

@ -17,6 +17,12 @@ migrate(
"analyses_secteur",
"notes_prospection",
"grille_prix",
"recherches_sauvegardees",
"alertes_recherche",
"annonces_veille",
"flux_sources",
"transactions_secteur",
"courriers_immobilier",
];
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
for (const name of names) {

View File

@ -0,0 +1,224 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* Fondations multi-agents : recherches sauvegardées, alertes, veille annonces,
* sources de flux, transactions secteur (stub DVF), courriers prospection.
*/
migrate(
(app) => {
const usersCol = app.findCollectionByNameOrId("users");
let usersId = "";
if (usersCol) {
const a = usersCol.id != null && String(usersCol.id) !== "" ? usersCol.id : null;
const b = usersCol.Id != null && String(usersCol.Id) !== "" ? usersCol.Id : null;
usersId = String(a != null ? a : b != null ? b : "").trim();
}
if (!usersId) {
throw new Error("migration 1760000000: collection users introuvable ou id vide");
}
function findExistingCollection(name) {
try {
return app.findCollectionByNameOrId(name);
} catch (_) {}
try {
const all = app.findAllCollections();
const want = String(name).toLowerCase();
for (let i = 0; i < all.length; i++) {
const c = all[i];
if (c && c.name && String(c.name).toLowerCase() === want) {
return c;
}
}
} catch (_) {}
return null;
}
function loadOrCreate(name, factory) {
const existing = findExistingCollection(name);
if (existing != null) {
return existing;
}
try {
const col = factory();
app.save(col);
return col;
} catch (err) {
const msg = String(err && err.value ? err.value : err && err.message ? err.message : err);
if (msg.includes("unique") || msg.includes("Unique")) {
const again = findExistingCollection(name);
if (again != null) {
return again;
}
}
throw err;
}
}
const ownRecords = '@request.auth.id != "" && user.id = @request.auth.id';
const authOnly = '@request.auth.id != ""';
function addUserRules(col) {
col.listRule = ownRecords;
col.viewRule = ownRecords;
col.createRule = authOnly;
col.updateRule = ownRecords;
col.deleteRule = ownRecords;
}
function userRel() {
return new RelationField({
name: "user",
required: true,
collectionId: usersId,
maxSelect: 1,
cascadeDelete: true,
});
}
loadOrCreate("recherches_sauvegardees", () => {
const col = new Collection({ name: "recherches_sauvegardees", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "nom", required: true }));
col.fields.add(new TextField({ name: "critere_json", required: false }));
col.fields.add(new BoolField({ name: "actif", required: false }));
addUserRules(col);
return col;
});
const rechRef = findExistingCollection("recherches_sauvegardees");
const rechId = rechRef ? String(rechRef.id || rechRef.Id || "").trim() : "";
if (!rechId) {
throw new Error("migration 1760000000: recherches_sauvegardees introuvable après création");
}
loadOrCreate("alertes_recherche", () => {
const col = new Collection({ name: "alertes_recherche", type: "base" });
col.fields.add(userRel());
col.fields.add(
new RelationField({
name: "recherche",
required: false,
collectionId: rechId,
maxSelect: 1,
cascadeDelete: false,
}),
);
col.fields.add(new TextField({ name: "nom", required: true }));
col.fields.add(
new SelectField({
name: "canal",
required: true,
maxSelect: 1,
values: ["in_app", "email", "push"],
}),
);
col.fields.add(new BoolField({ name: "actif", required: false }));
col.fields.add(new TextField({ name: "derniere_verification", required: false }));
col.fields.add(new NumberField({ name: "dernier_nb_resultats", required: false }));
addUserRules(col);
return col;
});
loadOrCreate("annonces_veille", () => {
const col = new Collection({ name: "annonces_veille", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "titre", required: true }));
col.fields.add(new TextField({ name: "url", required: false }));
col.fields.add(new TextField({ name: "source", required: false }));
col.fields.add(new NumberField({ name: "prix", required: false }));
col.fields.add(new NumberField({ name: "surface", required: false }));
col.fields.add(new TextField({ name: "code_postal", required: false }));
col.fields.add(new TextField({ name: "ville", required: false }));
col.fields.add(new TextField({ name: "empreinte", required: false }));
col.fields.add(
new SelectField({
name: "statut",
required: true,
maxSelect: 1,
values: ["nouveau", "vu", "ecarte", "raccroche"],
}),
);
addUserRules(col);
return col;
});
loadOrCreate("flux_sources", () => {
const col = new Collection({ name: "flux_sources", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "nom", required: true }));
col.fields.add(
new SelectField({
name: "type",
required: true,
maxSelect: 1,
values: ["api", "manuel", "csv"],
}),
);
col.fields.add(new TextField({ name: "notes", required: false }));
col.fields.add(new BoolField({ name: "actif", required: false }));
addUserRules(col);
return col;
});
loadOrCreate("transactions_secteur", () => {
const col = new Collection({ name: "transactions_secteur", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "libelle", required: true }));
col.fields.add(new TextField({ name: "code_insee", required: false }));
col.fields.add(new NumberField({ name: "annee", required: false }));
col.fields.add(new NumberField({ name: "prix_m2_median", required: false }));
col.fields.add(new NumberField({ name: "nb_ventes", required: false }));
col.fields.add(
new SelectField({
name: "source",
required: true,
maxSelect: 1,
values: ["manuel", "dvf_import", "api_tiers"],
}),
);
col.fields.add(new TextField({ name: "detail_json", required: false }));
addUserRules(col);
return col;
});
loadOrCreate("courriers_immobilier", () => {
const col = new Collection({ name: "courriers_immobilier", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "titre", required: true }));
col.fields.add(new TextField({ name: "corps", required: false }));
col.fields.add(
new SelectField({
name: "kind",
required: true,
maxSelect: 1,
values: ["prospection", "annonce_agence", "relance"],
}),
);
col.fields.add(
new SelectField({
name: "etat",
required: true,
maxSelect: 1,
values: ["brouillon", "pret"],
}),
);
addUserRules(col);
return col;
});
},
(app) => {
const names = [
"alertes_recherche",
"annonces_veille",
"flux_sources",
"transactions_secteur",
"courriers_immobilier",
"recherches_sauvegardees",
];
for (const name of names) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch (_) {}
}
},
);