200 lines
6.3 KiB
PHP
200 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Fox;
|
|
|
|
use App\Models\Fox\FoxLearningEvent;
|
|
use App\Models\Fox\FoxMemoryEdge;
|
|
use App\Models\Fox\FoxMemoryNode;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class FoxMemoryService
|
|
{
|
|
public function createNode(array $data): FoxMemoryNode
|
|
{
|
|
return FoxMemoryNode::create([
|
|
'type' => $data['type'] ?? 'fact',
|
|
'title' => $data['title'],
|
|
'content' => $data['content'] ?? null,
|
|
'meta' => $data['meta'] ?? null,
|
|
'confidence' => $data['confidence'] ?? 1.0,
|
|
'weight' => $data['weight'] ?? 1,
|
|
]);
|
|
}
|
|
|
|
public function updateNode(FoxMemoryNode $node, array $data): FoxMemoryNode
|
|
{
|
|
$node->update(array_filter([
|
|
'type' => $data['type'] ?? null,
|
|
'title' => $data['title'] ?? null,
|
|
'content' => $data['content'] ?? null,
|
|
'meta' => $data['meta'] ?? null,
|
|
'confidence' => $data['confidence'] ?? null,
|
|
'weight' => $data['weight'] ?? null,
|
|
], fn ($v) => $v !== null));
|
|
|
|
return $node->fresh();
|
|
}
|
|
|
|
public function createEdge(
|
|
FoxMemoryNode $source,
|
|
FoxMemoryNode $target,
|
|
string $relation,
|
|
float $strength = 1.0,
|
|
array $meta = [],
|
|
): FoxMemoryEdge {
|
|
return FoxMemoryEdge::create([
|
|
'source_node_id' => $source->id,
|
|
'target_node_id' => $target->id,
|
|
'relation' => $relation,
|
|
'strength' => $strength,
|
|
'meta' => empty($meta) ? null : $meta,
|
|
]);
|
|
}
|
|
|
|
public function storeLearningEvent(
|
|
string $input,
|
|
array $extractedMemory = [],
|
|
string $status = FoxLearningEvent::STATUS_PENDING,
|
|
): FoxLearningEvent {
|
|
return FoxLearningEvent::create([
|
|
'input' => $input,
|
|
'extracted_memory' => empty($extractedMemory) ? null : $extractedMemory,
|
|
'status' => $status,
|
|
]);
|
|
}
|
|
|
|
public function approveLearningEvent(FoxLearningEvent $event): void
|
|
{
|
|
$event->update([
|
|
'status' => FoxLearningEvent::STATUS_APPROVED,
|
|
'approved_at' => now(),
|
|
]);
|
|
|
|
$memory = $event->extracted_memory ?? [];
|
|
if (empty($memory)) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($memory) {
|
|
foreach ($memory['nodes'] ?? [] as $nodeData) {
|
|
$existing = FoxMemoryNode::where('title', $nodeData['title'])
|
|
->where('type', $nodeData['type'] ?? 'fact')
|
|
->first();
|
|
|
|
if ($existing) {
|
|
$this->updateNode($existing, $nodeData);
|
|
} else {
|
|
$this->createNode($nodeData);
|
|
}
|
|
}
|
|
|
|
foreach ($memory['edges'] ?? [] as $edgeData) {
|
|
$source = FoxMemoryNode::where('title', $edgeData['source'])->first();
|
|
$target = FoxMemoryNode::where('title', $edgeData['target'])->first();
|
|
|
|
if ($source && $target) {
|
|
$exists = FoxMemoryEdge::where('source_node_id', $source->id)
|
|
->where('target_node_id', $target->id)
|
|
->where('relation', $edgeData['relation'])
|
|
->exists();
|
|
|
|
if (! $exists) {
|
|
$this->createEdge(
|
|
$source,
|
|
$target,
|
|
$edgeData['relation'],
|
|
(float) ($edgeData['strength'] ?? 1.0),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public function rejectLearningEvent(FoxLearningEvent $event): void
|
|
{
|
|
$event->update([
|
|
'status' => FoxLearningEvent::STATUS_REJECTED,
|
|
'rejected_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Simple relevance search via title/content LIKE match.
|
|
* Structure ready for vector embeddings later — swap this method body.
|
|
*/
|
|
public function searchRelevantMemories(string $query, int $limit = 10): Collection
|
|
{
|
|
if (blank($query)) {
|
|
return collect();
|
|
}
|
|
|
|
$terms = collect(explode(' ', $query))
|
|
->filter(fn ($t) => mb_strlen($t) > 2)
|
|
->take(5);
|
|
|
|
$q = FoxMemoryNode::query();
|
|
|
|
foreach ($terms as $term) {
|
|
$q->orWhere('title', 'ilike', "%{$term}%")
|
|
->orWhere('content', 'ilike', "%{$term}%")
|
|
->orWhere('type', 'ilike', "%{$term}%");
|
|
}
|
|
|
|
$results = $q->orderByDesc('weight')
|
|
->orderByDesc('confidence')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
$results->each(fn (FoxMemoryNode $n) => $n->touchUsed());
|
|
|
|
return $results;
|
|
}
|
|
|
|
public function buildGraphPayload(): array
|
|
{
|
|
$nodes = FoxMemoryNode::orderByDesc('weight')->get();
|
|
$edges = FoxMemoryEdge::all();
|
|
|
|
$typeColors = [
|
|
'user' => '#22d3ee',
|
|
'project' => '#818cf8',
|
|
'place' => '#f59e0b',
|
|
'device' => '#34d399',
|
|
'skill' => '#a78bfa',
|
|
'preference' => '#fb7185',
|
|
'fact' => '#ffffff',
|
|
];
|
|
|
|
return [
|
|
'nodes' => $nodes->map(fn (FoxMemoryNode $n) => [
|
|
'id' => $n->id,
|
|
'label' => $n->title,
|
|
'type' => $n->type,
|
|
'color' => $typeColors[$n->type] ?? '#818cf8',
|
|
'content' => $n->content,
|
|
'weight' => $n->weight,
|
|
'confidence' => $n->confidence,
|
|
])->values()->all(),
|
|
|
|
'edges' => $edges->map(fn (FoxMemoryEdge $e) => [
|
|
'id' => $e->id,
|
|
'source' => $e->source_node_id,
|
|
'target' => $e->target_node_id,
|
|
'relation' => $e->relation,
|
|
'strength' => $e->strength,
|
|
])->values()->all(),
|
|
];
|
|
}
|
|
|
|
public function stats(): array
|
|
{
|
|
return [
|
|
'nodes_count' => FoxMemoryNode::count(),
|
|
'edges_count' => FoxMemoryEdge::count(),
|
|
'pending_count' => FoxLearningEvent::pending()->count(),
|
|
];
|
|
}
|
|
}
|