OASIS_MOBILE/src/AI/buildAIContext.js
oasis-bot 948b6460d9 upstream: integrar Oasis 1.3.3
Diferencia 1.3.1 -> 1.3.3 del codigo de la APK oficial aplicada a tres
bandas encima de la rama: karma en el perfil, salas con directo, fotos
selladas, visibilidad de menciones, telefono por stream en Android y
las traducciones nuevas. Sin dependencias nuevas.

OasisMobile.css sigue siendo el del fork; del oficial solo se traen al
final las reglas de las piezas nuevas (karma, directo a pantalla
completa, salas en vivo, graphos).
2026-10-19 00:30:25 +02:00

238 lines
9.3 KiB
JavaScript

const { readTyped } = require('../models/typed_log')
const { getConfig } = require('../configs/config-manager.js')
const logLimit = getConfig().ssbLogStream?.limit || 1000
let cooler = null
let ssb = null
let opening = null
function getCooler() {
let ssbPath = null
try { ssbPath = require.resolve('../server/SSB_server.js') } catch {}
if (ssbPath && require.cache[ssbPath]) {
if (!cooler) {
const gui = require('../client/gui.js')
cooler = gui({ offline: false })
}
return cooler
}
return null
}
function useCooler(c) {
cooler = c || null
ssb = null
opening = null
}
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true'
const debug = (msg) => { if (DEBUG) console.error(`[ai] ${msg}`) }
async function openSsb() {
const c = cooler || getCooler()
if (!c) return null
if (ssb && ssb.closed === false) return ssb
if (!opening) {
debug('opening the SSB connection for the network knowledge')
opening = c.open().then(x => { debug('SSB connection open'); return (ssb = x) }).finally(() => { opening = null })
}
await opening
return ssb
}
const clip = (s, n) => String(s || '').slice(0, n)
const squash = s => String(s || '').replace(/\s+/g, ' ').trim()
const compact = s => squash(clip(s, 160))
const normalize = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w\u0080-\uFFFF\s]+/g, '').trim()
function fieldsForSnippet(type, c) {
if (type === 'aiExchange') return [c?.question, clip(squash(c?.answer || ''), 120)]
return []
}
async function publishExchange({ q, a, ctx = [], tokens = {}, lang = '', tags = [], rating = 0 }) {
const s = await openSsb()
if (!s) return null
const safeLang = String(lang || '').trim().slice(0, 8).toLowerCase()
const safeTags = Array.isArray(tags)
? Array.from(new Set(tags.map(t => String(t || '').trim().slice(0, 32)).filter(Boolean))).slice(0, 10)
: []
const safeRating = Math.max(0, Math.min(5, Math.round(Number(rating) || 0)))
const content = {
type: 'aiExchange',
question: clip(String(q || ''), 2000),
answer: clip(String(a || ''), 5000),
ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
timestamp: Date.now()
}
if (safeLang) content.lang = safeLang
if (safeTags.length) content.tags = safeTags
if (safeRating > 0) content.rating = safeRating
return new Promise((resolve, reject) => {
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
})
}
async function publishExchangeVote({ targetId, helpful = true }) {
const s = await openSsb()
if (!s || !targetId) return null
const content = {
type: 'aiExchangeVote',
target: String(targetId),
helpful: !!helpful,
timestamp: Date.now()
}
return new Promise((resolve, reject) => {
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
})
}
async function buildContext(maxItems = 100) {
const s = await openSsb()
if (!s) return ''
const msgs = (await readTyped(s, ['aiExchange'], { limit: logLimit }).catch(() => null) || []).reverse()
const lines = []
for (const { value } of msgs) {
const c = value && value.content || {}
if (c.type !== 'aiExchange') continue
const d = new Date(value.timestamp || 0).toISOString().slice(0, 10)
const q = compact(c.question)
const a = compact(c.answer)
lines.push(`[${d}] (AIExchange) Q: ${q} | A: ${a}`)
if (lines.length >= maxItems) break
}
if (lines.length === 0) return ''
return `## AIEXCHANGE\n\n${lines.join('\n')}`
}
const fs = require('fs')
const VECTORS_FILE = 'AI-vectors.json'
const VECTORS_MAX = 4000
const RATING_WEIGHT = 0.03
const VOTE_WEIGHT = 0.02
const CONTEXT_MIN_SCORE = 0.35
const DIRECT_ANSWER_MIN = 0.92
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
const exchangeText = (c) => clip(squash(c.question), 300)
async function trustedAuthors(s) {
const me = s.id
const msgs = await readTyped(s, ['contact'], { limit: logLimit }).catch(() => null) || []
const latest = new Map()
for (const m of msgs) {
const v = m && m.value || {}
const c = v.content || {}
if (v.author !== me || typeof c.contact !== 'string') continue
const ts = Number(v.timestamp) || 0
const prev = latest.get(c.contact)
if (prev && prev.ts > ts) continue
latest.set(c.contact, { ts, on: c.following === true && c.blocking !== true })
}
const out = new Set([me])
for (const [id, st] of latest) if (st.on) out.add(id)
return out
}
async function listExchanges() {
const s = await openSsb()
if (!s) return { exchanges: [], votes: new Map() }
const msgs = await readTyped(s, ['aiExchange', 'aiExchangeVote'], { limit: logLimit }).catch(() => null) || []
const trusted = await trustedAuthors(s)
const exchanges = []
const votes = new Map()
for (const m of msgs) {
const c = m && m.value && m.value.content || {}
if (!trusted.has(m.value.author)) continue
if (c.type === 'aiExchange' && c.question && c.answer) {
exchanges.push({ key: m.key, author: m.value.author, ts: m.value.timestamp || m.timestamp || 0, question: String(c.question), answer: String(c.answer), rating: Number(c.rating) || 0, tags: Array.isArray(c.tags) ? c.tags : [], lang: c.lang || '' })
} else if (c.type === 'aiExchangeVote' && c.target) {
const k = String(c.target)
const per = votes.get(k) || new Map()
per.set(m.value.author, c.helpful !== false ? 1 : -1)
votes.set(k, per)
}
}
return { exchanges, votes }
}
const netVotes = (votes, key) => { const per = votes.get(key); if (!per) return 0; let n = 0; for (const v of per.values()) n += v; return n }
async function rankExchanges(question, { embed, cosine, k = 5, excludeAuthors = null } = {}) {
const listed = await listExchanges()
const votes = listed.votes
const exchanges = excludeAuthors instanceof Set && excludeAuthors.size
? listed.exchanges.filter(ex => !excludeAuthors.has(String(ex.author)))
: listed.exchanges
if (!exchanges.length) return []
const scored = []
if (typeof embed === 'function' && typeof cosine === 'function') {
const qv = await embed(question).catch(() => null)
if (qv) {
const cache = readVectors()
let dirty = false
const newest = exchanges.slice().sort((a, b) => b.ts - a.ts).slice(0, VECTORS_MAX)
for (const ex of newest) {
let v = cache[ex.key]
if (!Array.isArray(v)) {
v = await embed(exchangeText(ex)).catch(() => null)
if (!v) continue
cache[ex.key] = v
dirty = true
}
const sim = cosine(qv, v)
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
}
if (dirty) {
const keep = new Set(newest.map(x => x.key))
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
writeVectors(cache)
}
}
}
if (!scored.length) {
const want = normalize(question)
const terms = want.split(' ').filter(t => t.length > 2)
for (const ex of exchanges) {
const text = normalize(ex.question + ' ' + ex.answer)
const hits = terms.filter(t => text.includes(t)).length
if (!hits) continue
const sim = normalize(ex.question) === want ? 1 : hits / Math.max(1, terms.length)
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
}
}
scored.sort((a, b) => b.score - a.score)
return scored.filter(x => x.similarity >= CONTEXT_MIN_SCORE).slice(0, k)
}
async function rankContext(question, opts = {}) {
const top = await rankExchanges(question, opts)
return top.map(ex => `Q: ${compact(ex.question)} | A: ${clip(squash(ex.answer), 400)}`)
}
async function getBestTrainedAnswer(question, opts = {}) {
const top = await rankExchanges(question, { ...opts, k: 1 })
const best = top[0]
if (!best || best.similarity < DIRECT_ANSWER_MIN || best.votes < 0) return null
return { answer: String(best.answer || '').trim(), ctx: [`Q: ${compact(best.question)}`], key: best.key }
}
async function exportFineTuning({ system = '' } = {}) {
const { exchanges, votes } = await listExchanges()
const lines = []
for (const ex of exchanges.sort((a, b) => a.ts - b.ts)) {
if (netVotes(votes, ex.key) < 0) continue
const messages = []
if (system) messages.push({ role: 'system', content: system })
messages.push({ role: 'user', content: ex.question })
messages.push({ role: 'assistant', content: ex.answer })
lines.push(JSON.stringify({ messages, meta: { key: ex.key, author: ex.author, lang: ex.lang, tags: ex.tags, rating: ex.rating, helpful: netVotes(votes, ex.key), ts: ex.ts } }))
}
return lines.join('\n') + (lines.length ? '\n' : '')
}
module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer, rankContext, rankExchanges, listExchanges, exportFineTuning, useCooler, DIRECT_ANSWER_MIN, CONTEXT_MIN_SCORE }