Backup #1962
| ID | 1962 |
| Dateipfad | /var/www/dev.campus.systemische-tools.de/src/Infrastructure/AI/ChatService.php |
| Version | 21 |
| Typ |
modified |
| Größe | 12.8 KB |
| Hash | 002930ce16f1f238bbae8b46d23922b8910ce01c39a7bb5dea485bae28082938 |
| Datum | 2025-12-28 02:29:04 |
| Geändert von | claude-code-hook |
| Grund | Claude Code Pre-Hook Backup vor Edit-Operation |
| Datei existiert |
Ja
|
Dateiinhalt
<?php
declare(strict_types=1);
namespace Infrastructure\AI;
// @responsibility: RAG-Pipeline für KI-Chat (Embedding, Suche, Antwort-Generierung)
use RuntimeException;
final readonly class ChatService
{
public function __construct(
private OllamaService $ollama,
private QdrantService $qdrant,
private ClaudeService $claude,
private ScoringService $scoring,
private ?SemanticEnrichmentService $semantic = null
) {
}
/**
* Executes a complete RAG chat pipeline: Embedding → Search → Context → LLM → Response
* If no collections selected, skips RAG steps (direct LLM query).
*
* @param array<string> $collections Qdrant collections to search (empty = no RAG)
* @return array{question: string, answer: string, sources: array, model: string, usage?: array, chunks_used: int}
* @throws RuntimeException If embedding/search/LLM fails
*/
public function chat(
string $question,
string $model = 'claude-opus-4-5-20251101',
array $collections = [],
int $limit = 5,
?string $stylePrompt = null,
?string $customSystemPrompt = null,
float $temperature = 0.7,
int $maxTokens = 4096
): array {
$searchResults = [];
$context = '';
// Only perform RAG if collections are selected
if ($collections !== []) {
// Step 1: Generate embedding for the question
try {
$queryEmbedding = $this->ollama->getEmbedding($question);
} catch (RuntimeException $e) {
throw new RuntimeException(
'Embedding generation failed: ' . $e->getMessage(),
0,
$e
);
}
if ($queryEmbedding === []) {
throw new RuntimeException('Embedding generation returned empty vector');
}
// Step 2: Search across all selected collections
try {
$searchResults = $this->searchMultipleCollections($queryEmbedding, $collections, $limit);
} catch (RuntimeException $e) {
throw new RuntimeException(
'Vector search failed: ' . $e->getMessage(),
0,
$e
);
}
// Step 2b: Semantic enrichment (graceful degradation - works without)
$semanticSummary = '';
if ($searchResults !== [] && $this->semantic !== null) {
$searchResults = $this->semantic->enrichSearchResults($searchResults);
$semanticSummary = $this->semantic->buildSemanticSummary($searchResults);
}
// Step 3: Build context from search results (if any found)
if ($searchResults !== []) {
$context = $this->buildContext($searchResults, 3000, $semanticSummary);
}
}
// Step 4: Parse model string and generate answer
$isOllama = str_starts_with($model, 'ollama:');
$isClaude = str_starts_with($model, 'claude-');
$hasContext = $context !== '';
if ($isClaude) {
try {
// Build prompt: RAG with context or direct question
if ($hasContext) {
$userPrompt = $this->claude->buildRagPrompt($question, $context);
} else {
$userPrompt = $question;
}
// Build system prompt hierarchy: Default -> Custom -> Style
if ($customSystemPrompt !== null && $customSystemPrompt !== '') {
$systemPrompt = $customSystemPrompt;
} else {
$systemPrompt = $hasContext
? $this->claude->getDefaultSystemPrompt()
: 'Du bist ein hilfreicher Assistent. Antworte auf Deutsch, präzise und hilfreich.';
}
// Append style prompt from author profile if provided
if ($stylePrompt !== null && $stylePrompt !== '') {
$systemPrompt .= "\n\n" . $stylePrompt;
}
$llmResponse = $this->claude->ask($userPrompt, $systemPrompt, $model, $maxTokens, $temperature);
$answer = $llmResponse['text'];
$usage = $llmResponse['usage'];
} catch (RuntimeException $e) {
throw new RuntimeException(
'Claude API request failed: ' . $e->getMessage(),
0,
$e
);
}
} elseif ($isOllama) {
try {
// Extract actual model name (remove "ollama:" prefix)
$ollamaModel = substr($model, 7);
// Build instruction from custom prompt and style
$instructions = [];
if ($customSystemPrompt !== null && $customSystemPrompt !== '') {
$instructions[] = $customSystemPrompt;
}
if ($stylePrompt !== null && $stylePrompt !== '') {
$instructions[] = $stylePrompt;
}
$instructionBlock = $instructions !== [] ? implode("\n\n", $instructions) . "\n\n" : '';
// Build prompt: RAG with context or direct question
if ($hasContext) {
$userPrompt = sprintf(
"%sKontext aus den Dokumenten:\n\n%s\n\n---\n\nFrage: %s",
$instructionBlock,
$context,
$question
);
} else {
$userPrompt = $instructionBlock . $question;
}
$answer = $this->ollama->generate($userPrompt, $ollamaModel, $temperature);
$usage = null;
} catch (RuntimeException $e) {
throw new RuntimeException(
'Ollama generation failed: ' . $e->getMessage(),
0,
$e
);
}
} else {
throw new RuntimeException(
sprintf('Unknown model "%s". Use claude-* or ollama:* format.', $model)
);
}
// Step 5: Extract source information
$sources = $this->extractSources($searchResults);
// Step 6: Assemble response
$response = [
'question' => $question,
'answer' => $answer,
'sources' => $sources,
'model' => $model,
'chunks_used' => count($searchResults),
];
if ($usage !== null) {
$response['usage'] = $usage;
}
return $response;
}
/** Builds context string from search results (respects maxTokens limit). */
private function buildContext(array $searchResults, int $maxTokens = 3000, string $semanticSummary = ''): string
{
$contextParts = [];
$totalChars = 0;
$maxChars = $maxTokens * 4; // Approximate: 1 token ~ 4 characters
// Prepend semantic summary if available
if ($semanticSummary !== '') {
$contextParts[] = "[Semantischer Kontext]\n" . $semanticSummary;
$totalChars += strlen($semanticSummary);
}
foreach ($searchResults as $index => $result) {
$payload = $result['payload'];
// Support both payload schemas: documents + dokumentation_chunks
$content = (string) ($payload['content'] ?? $payload['content_preview'] ?? '');
$docTitle = (string) ($payload['document_title'] ?? $payload['title'] ?? 'Unbekannt');
// Check if adding this chunk would exceed the limit
if ($totalChars + strlen($content) > $maxChars) {
break;
}
// Build chunk header with optional entity info
$entities = $payload['entities'] ?? [];
$entityInfo = '';
if ($entities !== []) {
$entityNames = array_slice(array_column($entities, 'name'), 0, 3);
$entityInfo = ' | Enthält: ' . implode(', ', $entityNames);
}
$contextParts[] = sprintf('[Quelle %d: %s%s]%s%s', $index + 1, $docTitle, $entityInfo, "\n", $content);
$totalChars += strlen($content);
}
return implode("\n\n---\n\n", $contextParts);
}
/**
* Searches across multiple Qdrant collections and merges results.
*
* Queries each collection with the same embedding vector, then merges
* and sorts results by score. Returns the top N results across all collections.
*
* @param array<int, float> $embedding The query embedding vector
* @param array<string> $collections Collections to search
* @param int $limit Maximum total results to return
*
* @return array<int, array{id: int|string, score: float, payload: array<string, mixed>}> Merged search results
*/
private function searchMultipleCollections(array $embedding, array $collections, int $limit): array
{
$allResults = [];
foreach ($collections as $collection) {
try {
$results = $this->qdrant->search($embedding, $collection, $limit);
// Add collection name to each result's payload for reference
foreach ($results as &$result) {
$result['payload']['_collection'] = $collection;
}
$allResults = array_merge($allResults, $results);
} catch (RuntimeException) {
// Skip unavailable collections, continue with others
continue;
}
}
// Apply weighted scoring (similarity + recency + authority)
foreach ($allResults as &$result) {
$processedAt = $result['payload']['processed_at'] ?? null;
$documentDate = $processedAt !== null
? new \DateTime($processedAt)
: new \DateTime();
$authorityScore = (float) ($result['payload']['authority_score'] ?? 0.5);
$result['weighted_score'] = $this->scoring->calculateScore(
$result['score'],
$documentDate,
$authorityScore
);
}
unset($result);
// Sort by weighted score descending
usort($allResults, static fn ($a, $b) => ($b['weighted_score'] ?? 0.0) <=> ($a['weighted_score'] ?? 0.0));
// Return top N results
return array_slice($allResults, 0, $limit);
}
/**
* Extracts unique source information from search results.
*
* Collects document titles and scores from the search results,
* deduplicating by title to provide a clean list of sources.
* Optionally includes content preview if available.
*
* @param array<int, array{id: int|string, score: float, payload: array<string, mixed>}> $searchResults Vector search results
*
* @return array<int, array{title: string, score: float, content?: string}> Deduplicated source information
*/
private function extractSources(array $searchResults): array
{
$sources = [];
$seen = [];
foreach ($searchResults as $result) {
$payload = $result['payload'];
// Support both payload schemas: documents + dokumentation_chunks
$docTitle = (string) ($payload['document_title'] ?? $payload['title'] ?? '');
// Skip empty titles or already seen titles
if ($docTitle === '' || isset($seen[$docTitle])) {
continue;
}
$source = [
'title' => $docTitle,
'score' => round($result['score'], 3),
];
// Optionally include content preview (support both field names)
$content = $payload['content'] ?? $payload['content_preview'] ?? null;
if (is_string($content) && $content !== '') {
$source['content'] = $content;
}
// Add collection info if available
if (isset($payload['_collection'])) {
$source['collection'] = $payload['_collection'];
}
// Add semantic data if available (graceful degradation)
$entities = $payload['entities'] ?? [];
if ($entities !== []) {
$source['entities'] = array_map(
static fn ($e) => ['name' => $e['name'], 'type' => $e['type']],
array_slice($entities, 0, 5)
);
}
$taxonomy = $payload['taxonomy'] ?? [];
if ($taxonomy !== []) {
$source['taxonomy'] = array_map(
static fn ($t) => $t['term_name'],
array_slice($taxonomy, 0, 3)
);
}
$sources[] = $source;
$seen[$docTitle] = true;
}
return $sources;
}
}
Vollständig herunterladen
Aktionen
Andere Versionen dieser Datei
| ID |
Version |
Typ |
Größe |
Datum |
| 1963 |
22 |
modified |
12.3 KB |
2025-12-28 02:29 |
| 1962 |
21 |
modified |
12.8 KB |
2025-12-28 02:29 |
| 1961 |
20 |
modified |
13.6 KB |
2025-12-28 02:28 |
| 1960 |
19 |
modified |
15.2 KB |
2025-12-28 02:28 |
| 1959 |
18 |
modified |
15.8 KB |
2025-12-28 02:28 |
| 1591 |
17 |
modified |
15.2 KB |
2025-12-27 00:14 |
| 1590 |
16 |
modified |
14.3 KB |
2025-12-27 00:14 |
| 1589 |
15 |
modified |
13.9 KB |
2025-12-27 00:13 |
| 1588 |
14 |
modified |
13.7 KB |
2025-12-27 00:13 |
| 1041 |
13 |
modified |
13.5 KB |
2025-12-25 01:55 |
| 1040 |
12 |
modified |
13.4 KB |
2025-12-25 01:55 |
| 1021 |
11 |
modified |
13.4 KB |
2025-12-24 16:22 |
| 1020 |
10 |
modified |
12.7 KB |
2025-12-24 16:21 |
| 1019 |
9 |
modified |
12.6 KB |
2025-12-24 16:21 |
| 747 |
8 |
modified |
13.0 KB |
2025-12-23 07:59 |
| 743 |
7 |
modified |
13.1 KB |
2025-12-23 07:58 |
| 85 |
6 |
modified |
11.5 KB |
2025-12-20 19:15 |
| 84 |
5 |
modified |
10.9 KB |
2025-12-20 19:15 |
| 83 |
4 |
modified |
10.9 KB |
2025-12-20 19:14 |
| 54 |
3 |
modified |
10.9 KB |
2025-12-20 18:30 |
| 53 |
2 |
modified |
10.9 KB |
2025-12-20 18:30 |
| 52 |
1 |
modified |
10.6 KB |
2025-12-20 18:30 |
← Zurück zur Übersicht