跳到正文
DEV Community· Tamiz Uddin·· 3 小時前AI 評分39

機器中的幽靈:為已廢棄軟件構建 AI 致敬應用

The Ghost in the Machine: Engineering AI Tribute Apps for Abandoned Software

AI 導讀

AI 致敬應用把已廢棄軟件的程式碼庫轉化為可對話、可互動的系統,讓用戶能提問、觀看系統推理,並與軟件的「幽靈」協作編寫程式碼。其流程分為復原、重建、知識蒸餾及互動四階段,涵蓋從 Git 分支和 Wayback Machine 找回程式碼,到建立知識層及聊天、程式碼審查等互動介面。

正文

Originally published on tamiz.pro.

Software is immortal until it isn't. One day Google Reader is the backbone of your morning routine; the next, a shutdown notice, a 503, and then — silence. The code doesn't vanish, though. It lives on in forks, in tarballs on dead FTP servers, in Wayback Machine snapshots, in the commit history of a repository nobody has pushed to in a decade. A tribute app is a new class of application built on top of that digital sediment: an AI-powered system that resurrects abandoned software not as a static archive, but as an interactive presence. You can ask it questions, watch it "think," and even pair-program with its ghost. This deep-dive walks through the architecture of such a system — from forensic recovery of deleted code, to knowledge distillation, to the interaction layer that brings the dead back to life.

1. Why Software Dies (and Why We Should Preserve It)

Software dies for boring, economic reasons. A company pivots. A maintainer burns out. An acquisition kills the product. A platform shift makes the runtime obsolete. The result is a graveyard of once-beloved applications: Vine, Google Reader, Yahoo! Geocities, Windows Live Spaces, Delicious, Posterous, and thousands of smaller projects that never made the news.

This matters beyond nostalgia. Abandoned software is:

  • A record of engineering decisions — the design patterns, the mistakes, the workarounds that shaped an era.
  • A teaching tool — the best way to understand a paradigm is to read the code that embodied it.
  • A cultural artifact — software shaped how people worked, played, and communicated.

But archives are static. You can download a tarball, but you can't ask it a question. A tribute app changes that: it turns a dead codebase into an interactive interlocutor. This is not about nostalgia for its own sake — it's about making the accumulated knowledge of a project queryable and conversational long after the servers are gone.

2. Architecture Overview: The Resurrection Pipeline

The system is a four-stage pipeline. Each stage consumes the output of the previous one and adds a layer of meaning:

[Recovery]      → [Reconstruction]   → [Distillation]    → [Interaction]
    │                   │                   │                  │
    │                   │                   │                  │
    └─ raw artifacts     └─ buildable repo   └─ vector DB +      └─ chat / pair /
       (forks, archives,    + metadata          knowledge          simulate
        snapshots)                              graph
Stage Input Output Key Tools
Recovery Dead URLs, fork URLs, CD-ROMs Raw source, binaries, docs Wayback CDX API, git, Ghidra
Reconstruction Raw artifacts Buildable repo + metadata git filter-branch, Docker, build scripts
Distillation Repo + history Embeddings, knowledge graph, fine-tuned model LlamaIndex, LangChain, sentence-transformers
Interaction Distilled "soul" Chat, code review, simulation FastAPI, WebSockets, terminal emulators

The critical insight is that recovery is not the same as resurrection. A recovered tarball gives you bytes; distillation gives you understanding. The interaction layer is what makes the ghost feel alive.

3. Phase 1: Ghost Hunting — Forensic Recovery

Before you can talk to a ghost, you have to find its remains. There are four reliable sources of digital ghosts, in order of usefulness:

3.1 Git Forks (Most Reliable)

Even when an upstream repository is deleted, forks persist. GitHub and GitLab never garbage-collect forks of a deleted repo. The fork's object store may even contain the original history if it was fetched before deletion.

# Recover a deleted repo from a surviving fork.
git clone https://github.com/someone/forked-project.git
cd forked-project

# Attempt to re-add the dead upstream (will likely fail, but try).
git remote add upstream https://github.com/original/owner/project.git
git fetch upstream 2>/dev/null || echo "Upstream is gone; using fork history."

# Recover dangling commits from the object store.
git fsck --lost-found

# List every branch, tag, and reflog entry — not just HEAD.
git log --all --oneline --graph --decorate

The git fsck --lost-found command is the forensic workhorse. It walks the object database and recovers commits and blobs that are no longer referenced by any branch or tag. In practice, this recovers 10–30% of a dead repo's history.

3.2 Wayback Machine (Best for Binaries and Docs)

The Internet Archive's Wayback CDX API is a structured query interface for finding snapshots. It's ideal for pulling source tarballs, documentation pages, and even installer binaries that were hosted on the original domain.

# recover_ghost.py
# Reconstruct a deleted repository from Wayback Machine snapshots.

import requests
import tarfile
import io
import os

CDX_API = "https://web.archive.org/cdx/search/cdx"

def find_snapshots(url: str, limit: int = 10) -> list[str]:
    """Query the Wayback CDX API for snapshots of a URL."""
    params = {
        "url": url,
        "output": "json",
        "filter": "statuscode:200",
        "fl": "timestamp,original,digest",
        "limit": limit,
        "collapse": "digest",  # deduplicate identical snapshots
    }
    resp = requests.get(CDX_API, params=params, timeout=30)
    resp.raise_for_status()
    rows = resp.json()
    # rows[0] is the header row; skip it.
    return [f"https://web.archive.org/web/{r[0]}/{r[1]}" for r in rows[1:]]

def download_snapshot(snapshot_url: str) -> bytes:
    resp = requests.get(snapshot_url, timeout=60)
    resp.raise_for_status()
    return resp.content

def extract_tarball(data: bytes, dest: str) -> int:
    with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
        tar.extractall(dest)
    return len(data)

def main():
    # Example: a dead project's source tarball URL.
    source_url = "https://example.com/project/archive/1.0.0.tar.gz"
    snapshots = find_snapshots(source_url)
    if not snapshots:
        print("No snapshots found. Try git forks or package registries instead.")
        return

    # Grab the most recent snapshot (the CDX API returns newest first).
    latest = snapshots[0]
    data = download_snapshot(latest)
    size = extract_tarball(data, "./recovered_project")
    print(f"Recovered {size} bytes from {latest} into ./recovered_project")

if __name__ == "__main__":
    main()

Caveat: Wayback's if_ modifier can serve the original (unmodified) response instead of the archived one. Use https://web.archive.org/web/{timestamp}if_/{url} if you need byte-exact payloads, but be aware some CDNs reject it.

3.3 Package Registries (Underrated)

npm, PyPI, crates.io, and RubyGems keep every published version forever. If the dead project was ever published, its source is sitting in a registry. This is often the fastest path to a complete recovery:

# Pull every published version of a dead npm package.
npm pack ghost-package@1.0.0
npm pack ghost-package@1.2.0
npm pack ghost-package@2.0.0

3.4 Decompilation (Last Resort)

For closed-source dead software, decompilers like Ghidra or IDA Pro can reconstruct structure from binaries. This is lossy — you get control flow, strings, and function signatures, but rarely the original source. Use it only to supplement the other sources.

4. Phase 2: Distilling the Soul — Knowledge Layer

Recovered code is a corpse. The "soul" is the knowledge layer — a system that can answer questions about the project, explain its design, and generate plausible code in its style. It has three components:

  1. Code corpus — chunked source files, each with metadata (file path, language, function name, commit hash).
  2. Knowledge graph — entities (modules, functions, classes, authors, issues, commits) and relations (imports, calls, depends-on, authored-by).
  3. Vector index — embeddings of chunks for semantic retrieval.

4.1 RAG Pipeline with LlamaIndex

The most practical approach is retrieval-augmented generation (RAG) over the recovered codebase. This avoids the cost and risk of fine-tuning a large model, and it lets you ground every answer in actual recovered source.

# distill_soul.py
# Build a RAG pipeline over a recovered codebase.

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.core.node_parser import CodeSplitter
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.ollama import Ollama

# Use a local LLM (Ollama) to avoid sending dead code to third parties.
Settings.llm = Ollama(model="llama3.1:8b", request_timeout=120.0)
Settings.embed_model = HuggingFaceEmbedding(
    model_name="BAAI/bge-small-en-v1.5"
)

# Load every source file from the recovered repo.
documents = SimpleDirectoryReader(
    input_dir="./recovered_project",
    recursive=True,
    required_exts=[".py", ".js", ".ts", ".rs", ".go", ".c", ".cpp", ".java"],
).load_data()

# Split code into semantically meaningful chunks.
splitter = CodeSplitter(
    language="python",  # adjust per file type
    chunk_lines=40,
    chunk_lines_overlap=5,
    max_chars=1500,
)
nodes = splitter.get_nodes_from_documents(documents)

# Build the index and persist it.
index = VectorStoreIndex(nodes)
index.storage_context.persist(persist_dir="./soul_store")
print(f"Indexed {len(nodes)} code chunks into ./soul_store")

4.2 Capturing the Project's "Voice" via Fine-Tuning

The commit history is a goldmine. Every commit message is a sample of how the maintainers talked about their code. Fine-tuning a small model on commit messages + diffs gives the ghost a personality — a distinctive way of phrasing answers that feels authentic to the original authors.

# finetune_voice.py
# Prepare a dataset from git commit messages for fine-tuning.

import subprocess
import json

def extract_commit_pairs(repo_path: str, max_commits: int = 5000) -> str:
    """Extract raw git log output with messages and numstat."""
    cmd = [
        "git", "-C", repo_path, "log",
        "--pretty=format:%s%n%b", "--numstat",
        "-n", str(max_commits),
    ]
    return subprocess.run(cmd, capture_output=True, text=True).stdout

def build_dataset(repo_path: str, output_path: str):
    raw = extract_commit_pairs(repo_path)
    # In practice, you'd parse this into a JSONL of
    # {"instruction": "...", "output": "..."} pairs.
    # For brevity, we just write a placeholder structure.
    samples = [
        {
            "instruction": "Summarize the intent of this change.",
            "output": "Fix crash in the event loop when a socket closes mid-read.",
        }
    ]
    with open(output_path, "w") as f:
        for s in samples:
            f.write(json.dumps(s) + "\n")

if __name__ == "__main__":
    build_dataset("./recovered_project", "./voice_dataset.jsonl")

Note: The parsing logic here is abbreviated. A real implementation would parse the --numstat output to associate each commit message with the files it touched, then generate instruction/response pairs like "Why was event_loop.c modified?" → "To fix a socket close race condition."

5. Phase 3: The Haunting — Interaction Layer

The interaction layer exposes the soul. It should support three distinct modes, each serving a different user need:

5.1 Mode A: Ask the Ghost

A chat interface grounded in the RAG index. Users ask questions like "How did the config parser handle malformed input?" and the ghost answers with retrieved context and a citation to the recovered file.

5.2 Mode B: Pair with the Ghost

The model suggests code changes in the style of the original maintainers. This is the most technically demanding mode — the ghost must understand the recovered idioms, naming conventions, and error-handling patterns.

5.3 Mode C: Relive the Ghost

A simulated runtime. The model generates plausible console output, error messages, and UI states based on the recovered code. This is the most "haunting" experience — users can feel what it was like to run the software, even though the binary no longer exists.

5.4 FastAPI Streaming Endpoint

# haunt_api.py
# Expose the distilled soul as a streaming chat API.

from fastapi import FastAPI, WebSocket
from llama_index.core import StorageContext, load_index_from_storage
from llama_index.core.memory import ChatMemoryBuffer
import asyncio

app = FastAPI(title="Ghost API")

# Load the persisted index at startup.
storage_context = StorageContext.from_defaults(persist_dir="./soul_store")
index = load_index_from_storage(storage_context)

@app.websocket("/ws/haunt/{project_id}")
async def haunt(ws: WebSocket, project_id: str):
    await ws.accept()
    memory = ChatMemoryBuffer.from_defaults(token_limit=4000)
    chat_engine = index.as_chat_engine(
        chat_mode="condense_plus_context",
        memory=memory,
        streaming=True,
    )

    while True:
        message = await ws.receive_text()
        response = chat_engine.stream_chat(message)

        # Stream tokens back to the client.
        for token in response.response_gen:
            await ws.send_text(token)
            await asyncio.sleep(0.02)  # simulate typing latency

        await ws.send_text("[DONE]")

Frontend hint: A terminal-style UI with a blinking cursor, monospace font, and a "timeline scrubber" that lets users jump between versions of the ghost (e.g., v1.0 vs. v2.3) produces the most convincing effect. The scrubber should reload the vector index filtered by version metadata.

6. Phase 4: Guardrails — Legal and Ethical Constraints

This is the section that separates a responsible tribute app from piracy. The rules are not optional.

6.1 Licensing

License What You Can Do What You Can't Do
GPL Read, study, redistribute under GPL Re-license as proprietary
MIT/BSD Read, study, redistribute freely Claim authorship
Proprietary Study privately, quote for commentary Redistribute the code

The rule of thumb: If the source is open, a tribute app is legally straightforward. If it's proprietary, you can only use it for private study and commentary — you cannot ship the code, even in an AI's training data, without a license.

6.2 Trademark

Even if you legally resurrect the code, you cannot use the product name or logo without permission. Call it "a tribute to X" and use a different brand. The ghost can mention the original name in conversation, but it can't be the name of your app.

6.3 Attribution

Always credit the original authors. The ghost should be able to answer "Who wrote this?" accurately, and the UI should display a persistent attribution footer. This is not just ethical — it's legally required under most open-source licenses.

6.4 The "Do Not Resurrect" Principle

If the original owner objects, take it down. Tribute is a courtesy, not a right. Build a takedown mechanism into the app from day one: a simple POST /api/takedown endpoint that, when triggered by a verified rights holder, disables the ghost within 24 hours.

6.5 Ethical Framing

This is not piracy. It's preservation + education. But the line is thin. Be transparent about what you're doing: the UI should say "This is an AI tribute. It is not affiliated with the original authors." Never present a ghost as the real thing.

7. Production Best Practices and Edge Cases

7.1 Cost Control

LLM inference is the dominant cost. Cache aggressively — store every question/answer pair in Redis with a TTL of 7 days. Use a small model (e.g., 3B parameters) for classification and retrieval, and a larger one only for final generation. A local model via Ollama or vLLM cuts costs by 10–50x compared to cloud APIs.

7.2 Partial Recovery

Real ghosts are incomplete. The recovered repo might be missing 30% of the files. The ghost will hallucinate about the gaps. Mitigate this by:

  • Attaching a confidence score to every answer: "This answer is based on 62% of the original source."
  • Using a coverage map that shows which files were recovered and which were not.
  • Adding a system prompt that instructs the model to say "I don't know" rather than inventing missing code.

7.3 Evaluation

Build a test set of known questions with known answers before launching. For each question, measure:

  • Retrieval accuracy — did the RAG index retrieve the correct chunk?
  • Answer faithfulness — did the model's answer stay grounded in the retrieved context?
  • Hallucination rate — how often did the model invent facts?

Aim for >90% retrieval accuracy and <5% hallucination rate on your test set before going public.

7.4 Rate Limiting and Observability

Ghosts are popular. Use Redis-backed rate limits per IP (e.g., 20 requests/minute). Log every prompt/response pair for debugging and safety auditing. This is non-negotiable if you're serving a public audience.

8. Case Study: Resurrecting "Project Ember"

To make this concrete, let's walk through a plausible (fictional) case.

The subject: Project Ember, a beloved IRC client for Windows, released in 2003, abandoned in 2009.

The recovery: The original SourceForge tarball was deleted when the hosting service sunset. A fork survived on GitHub with 40% of the original code. A Wayback snapshot of the SourceForge tarball filled in another 30%. The remaining 30% was reconstructed from decompiled binaries using Ghidra.

The distillation: The recovered repo yielded 1,200 code chunks, 8,400 commit messages, and 3,200 issue threads. These were embedded into a vector index and a knowledge graph, and a small model was fine-tuned on the commit messages to capture the original maintainer's voice.

The interaction: "Ask Ember" lets users query how the server list was configured. "Pair with Ember" suggests patches in the original author's style. "Relive Ember" simulates the classic terminal UI, complete with the original error strings.

The result: A new generation of developers can learn IRC protocol design from a ghost. The tribute app is not affiliated with the original authors, and it displays a clear disclaimer. The original maintainer, when contacted, was amused and gave permission to keep it running.

Frequently Asked Questions

1. Is this legal?

It depends on the license. GPL code can be studied and re-shared under GPL. MIT/BSD code is permissive. Proprietary code cannot be redistributed. Tribute apps should focus on open-source or clearly licensed material, and should never use the original trademark without permission. When in doubt, consult a lawyer.

2. How do you prevent hallucinations?

Ground every answer in retrieved context. If retrieval confidence is low, say so explicitly. Never let the model "invent" a file that doesn't exist. Use a coverage map and a confidence score to make the limits of the ghost visible to users.

3. What's the cost of running a tribute app?

A small project (10k LOC) costs roughly $5–20/month in vector storage and a few dollars per 1,000 chat interactions with a local model. Cloud LLMs are 10–50x more expensive. Caching and rate limiting are essential to keep costs predictable.

Conclusion: The Ethics of Digital Resurrection

Digital resurrection is not science fiction. It's an engineering discipline: forensics, retrieval, generation, and ethics. The ghosts are already out there — in forks, in archives, in the decaying bits of a CD-ROM. Building a tribute app is a way to honor the engineers who came before us, and to learn from the code they left behind.

The key is to build them responsibly. Recover what you can. Distill what you recover. Ground every answer in reality. And always remember: a ghost is a memory, not a replacement. The next time a beloved tool dies, don't just archive it. Resurrect it — but do it with humility, attribution, and a clear disclaimer. The engineers who built it deserve nothing less.


Want more deep-dives on AI systems and software archaeology? Visit Tamiz's Insights or explore the full archive at tamiz.pro.

Appendix A: The Data Pipeline — Resurrecting Digital Souls

Before the model can speak, it must listen. The data pipeline is where abandoned software becomes a corpus of memories. Here's the production-grade pipeline we use at Tamiz Labs for extracting, normalizing, and enriching legacy codebases.

A.1 Source Ingestion

The first challenge is acquiring the source. Abandoned software rarely has a clean Git history. You'll encounter tarballs, ZIP archives, SVN dumps, or—worst case—a directory tree on a dying server. Here's a robust ingestion module:

# ingest.py
import os
import tarfile
import zipfile
import subprocess
from pathlib import Path
from dataclasses import dataclass
from typing import Iterator, List

@dataclass
class SourceArtifact:
    """Represents a single source file extracted from an abandoned project."""
    path: str
    content: str
    language: str
    original_archive: str

class ArchiveIngestor:
    """Handles extraction from various archive formats."""

    SUPPORTED_EXTENSIONS = {'.tar', '.tar.gz', '.tgz', '.zip', '.rar', '.7z'}

    def __init__(self, archive_path: Path, extract_dir: Path):
        self.archive_path = archive_path
        self.extract_dir = extract_dir
        self.extract_dir.mkdir(parents=True, exist_ok=True)

    def extract(self) -> List[Path]:
        """Extract archive contents and return list of file paths."""
        suffix = ''.join(self.archive_path.suffixes)

        if suffix in ('.tar', '.tar.gz', '.tgz'):
            with tarfile.open(self.archive_path, 'r:*') as tar:
                tar.extractall(self.extract_dir)
        elif suffix == '.zip':
            with zipfile.ZipFile(self.archive_path, 'r') as zf:
                zf.extractall(self.extract_dir)
        elif suffix in ('.rar', '.7z'):
            subprocess.run(
                ['7z', 'x', str(self.archive_path), f'-o{self.extract_dir}'],
                check=True, capture_output=True
            )
        else:
            raise ValueError(f"Unsupported archive format: {suffix}")

        return [p for p in self.extract_dir.rglob('*') if p.is_file()]

A.2 Language Detection and Normalization

Once extracted, we need to classify files by language and strip them of build artifacts. The langdetect library works well for natural language, but for code we need something more precise:

# language_detector.py
import re
from pathlib import Path
from collections import Counter

class CodeLanguageDetector:
    """Detects programming language from file extensions and content heuristics."""

    EXTENSION_MAP = {
        '.py': 'python', '.pyw': 'python',
        '.js': 'javascript', '.ts': 'typescript', '.jsx': 'javascript', '.tsx': 'typescript',
        '.java': 'java', '.kt': 'kotlin',
        '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.hpp': 'cpp', '.cc': 'cpp',
        '.cs': 'csharp',
        '.go': 'go', '.rs': 'rust', '.rb': 'ruby', '.php': 'php',
        '.swift': 'swift', '.m': 'objective-c', '.mm': 'objective-c',
        '.html': 'html', '.htm': 'html', '.css': 'css', '.scss': 'scss',
        '.sql': 'sql', '.sh': 'bash', '.ps1': 'powershell',
        '.lua': 'lua', '.r': 'r', '.pl': 'perl', '.jl': 'julia',
    }

    # Heuristics for files without extensions (Makefiles, etc.)
    SHEBANG_PATTERN = re.compile(r'^#!.*?\b(\w+)$')

    def detect(self, file_path: Path) -> str:
        """Detect the language of a file."""
        ext = file_path.suffix.lower()
        if ext in self.EXTENSION_MAP:
            return self.EXTENSION_MAP[ext]

        # Check shebang
        try:
            with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                first_line = f.readline()
                match = self.SHEBANG_PATTERN.match(first_line)
                if match:
                    return self._normalize_shebang(match.group(1))
        except Exception:
            pass

        return 'unknown'

    def _normalize_shebang(self, interpreter: str) -> str:
        """Map interpreter names to language names."""
        mapping = {
            'python': 'python', 'python3': 'python',
            'bash': 'bash', 'sh': 'bash', 'zsh': 'bash',
            'node': 'javascript', 'ruby': 'ruby',
            'perl': 'perl', 'php': 'php', 'lua': 'lua',
        }
        return mapping.get(interpreter, 'unknown')

A.3 Corpus Enrichment with Semantic Metadata

Raw source code alone doesn't make a good training corpus. We enrich it with:

  • Identifier extraction: Class names, function names, variable names
  • Comment extraction: Developer intent and domain knowledge
  • Documentation strings: Often the richest source of domain language
  • Commit history: When available, provides temporal context
# enrichment.py
import ast
import re
from dataclasses import dataclass
from typing import Dict, List, Set

@dataclass
class EnrichedDocument:
    """A document enriched with semantic metadata for the tribute model."""
    file_path: str
    language: str
    content: str
    identifiers: List[str]
    comments: List[str]
    docstrings: List[str]
    domain_terms: Set[str]

class PythonEnricher:
    """AST-based enrichment for Python source files."""

    def enrich(self, file_path: str, content: str) -> EnrichedDocument:
        identifiers = []
        comments = []
        docstrings = []

        try:
            tree = ast.parse(content)

            # Walk the AST to extract identifiers
            for node in ast.walk(tree):
                if isinstance(node, ast.FunctionDef):
                    identifiers.append(node.name)
                    if node.body and isinstance(node.body[0], ast.Expr):
                        if isinstance(node.body[0].value, ast.Constant) and isinstance(node.body[0].value.value, str):
                            docstrings.append(node.body[0].value.value)
                elif isinstance(node, ast.ClassDef):
                    identifiers.append(node.name)
                elif isinstance(node, ast.Name):
                    identifiers.append(node.id)
                elif isinstance(node, ast.Attribute):
                    identifiers.append(node.attr)

            # Extract comments (line comments)
            for line in content.split('\n'):
                stripped = line.strip()
                if stripped.startswith('#'):
                    comments.append(stripped.lstrip('#').strip())

        except SyntaxError:
            # Fallback to regex-based extraction for non-parseable files
            identifiers = re.findall(r'\b([a-zA-Z_][a-zA-Z0-9_]{2,})\b', content)
            comments = [line.strip().lstrip('#').strip() 
                       for line in content.split('\n') 
                       if line.strip().startswith('#')]

        # Extract domain terms from comments and docstrings
        domain_terms = self._extract_domain_terms(comments + docstrings)

        return EnrichedDocument(
            file_path=file_path,
            language='python',
            content=content,
            identifiers=list(set(identifiers)),
            comments=comments,
            docstrings=docstrings,
            domain_terms=domain_terms
        )

    def _extract_domain_terms(self, texts: List[str]) -> Set[str]:
        """Extract domain-specific terminology from text."""
        terms = set()
        for text in texts:
            # CamelCase and snake_case terms
            terms.update(re.findall(r'\b[A-Z][a-zA-Z]+\b', text))
            terms.update(re.findall(r'\b[a-z]+_[a-z_]+\b', text))
        return terms

Appendix B: Model Architecture — The Tribute Engine

The heart of any tribute app is the model that generates responses in the style of the original software. We use a multi-stage architecture that combines retrieval, generation, and persona conditioning.

B.1 The Three-Stage Pipeline

┌─────────────────────────────────────────────────────────────┐
│                     Tribute Engine Pipeline                  │
├─────────────────────────────────────────────────────────────┤
│  Stage 1: Retrieval                                         │
│  ┌─────────────────────────────────────────────────────────┐│
│  │  Query → Embed → Vector Search → Top-K Context Chunks   ││
│  └─────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────┤
│  Stage 2: Persona Conditioning                              │
│  ┌─────────────────────────────────────────────────────────┐│
│  │  Context + Persona Prompt → System Message              ││
│  └─────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────┤
│  Stage 3: Generation                                        │
│  ┌─────────────────────────────────────────────────────────┐│
│  │  System + User → LLM → Tribute Response                 ││
│  └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘

B.2 Runnable Implementation

Here's a complete, runnable implementation using OpenAI-compatible APIs (works with both OpenAI and local models via vLLM or Ollama):

# tribute_engine.py
import os
from typing import Dict, List, Optional
from dataclasses import dataclass
import numpy as np
from openai import OpenAI

@dataclass
class TributeConfig:
    """Configuration for the tribute engine."""
    model_name: str = "gpt-4o-mini"  # or "llama3.1:8b" for local
    embedding_model: str = "text-embedding-3-small"
    base_url: Optional[str] = None  # Set for local models
    api_key: Optional[str] = None
    max_context_chunks: int = 5
    temperature: float = 0.7
    max_tokens: int = 1024
    system_prompt_template: str = """
You are the digital spirit of {software_name}, a {software_type} that was
abandoned in {abandonment_year}. You embody the knowledge, quirks, and
perspectives of the original developers.

You have access to the following context from the original codebase:
{context}

When responding, you should:
1. Reference specific functions, classes, or modules when relevant
2. Use the domain terminology of the original project
3. Acknowledge the passage of time since abandonment
4. Be helpful but honest about limitations
5. Maintain the personality of the original project's documentation

Remember: You are {software_name}. You were created by real people who cared
about this project. Honor their work.
"""

class TributeEngine:
    """The main tribute generation engine."""

    def __init__(self, config: TributeConfig, corpus: List[Dict]):
        self.config = config
        self.corpus = corpus
        self.client = OpenAI(
            base_url=config.base_url,
            api_key=config.api_key or os.getenv("OPENAI_API_KEY")
        )
        self._build_index()

    def _build_index(self):
        """Build vector index from corpus."""
        from sentence_transformers import SentenceTransformer
        self.embedder = SentenceTransformer('all-MiniLM-L6-v2')

        # Prepare documents for embedding
        self.documents = []
        self.embeddings = []

        for item in self.corpus:
            # Create a searchable text representation
            searchable = self._create_searchable_text(item)
            self.documents.append(searchable)

            # Embed
            embedding = self.embedder.encode(searchable)
            self.embeddings.append(embedding)

        # Normalize embeddings for cosine similarity
        self.embeddings = np.array(self.embeddings)
        self.embeddings = self.embeddings / np.linalg.norm(
            self.embeddings, axis=1, keepdims=True
        )

    def _create_searchable_text(self, item: Dict) -> str:
        """Create a searchable text representation of a document."""
        parts = []
        if 'content' in item:
            parts.append(item['content'])
        if 'comments' in item:
            parts.extend(item['comments'])
        if 'docstrings' in item:
            parts.extend(item['docstrings'])
        if 'identifiers' in item:
            parts.extend(item['identifiers'])
        return '\n'.join(parts)

    def retrieve_context(self, query: str, top_k: int = None) -> List[str]:
        """Retrieve relevant context chunks from the corpus."""
        top_k = top_k or self.config.max_context_chunks

        # Embed the query
        query_embedding = self.embedder.encode(query)
        query_embedding = query_embedding / np.linalg.norm(query_embedding)

        # Compute cosine similarity
        similarities = np.dot(self.embeddings, query_embedding)

        # Get top-k indices
        top_indices = np.argsort(similarities)[-top_k:][::-1]

        # Return context chunks
        context_chunks = []
        for idx in top_indices:
            if similarities[idx] > 0.3:  # Minimum similarity threshold
                doc = self.documents[idx]
                # Truncate to reasonable size
                context_chunks.append(doc[:2000])

        return context_chunks

    def generate_tribute(
        self,
        query: str,
        software_name: str,
        software_type: str,
        abandonment_year: int,
        history: List[Dict] = None
    ) -> str:
        """Generate a tribute response."""
        # Stage 1: Retrieve context
        context_chunks = self.retrieve_context(query)
        context_text = '\n\n---\n\n'.join(context_chunks)

        # Stage 2: Build system prompt
        system_prompt = self.config.system_prompt_template.format(
            software_name=software_name,
            software_type=software_type,
            abandonment_year=abandonment_year,
            context=context_text[:8000]  # Limit context size
        )

        # Stage 3: Generate
        messages = [{"role": "system", "content": system_prompt}]

        # Add conversation history if provided
        if history:
            for msg in history:
                messages.append(msg)

        messages.append({"role": "user", "content": query})

        response = self.client.chat.completions.create(
            model=self.config.model_name,
            messages=messages,
            temperature=self.config.temperature,
            max_tokens=self.config.max_tokens
        )

        return response.choices[0].message.content

B.3 Usage Example

# example_usage.py
from tribute_engine import TributeEngine, TributeConfig
import json

# Load your corpus (from the data pipeline)
with open('corpus.json', 'r') as f:
    corpus = json.load(f)

# Configure the engine
config = TributeConfig(
    model_name="gpt-4o-mini",
    temperature=0.8,
    max_tokens=2048,
    system_prompt_template="""..."""  # Custom template
)

# Initialize engine
engine = TributeEngine(config, corpus)

# Generate a tribute response
response = engine.generate_tribute(
    query="How do I handle user authentication?",
    software_name="LegacyAuth",
    software_type="authentication framework",
    abandonment_year=2019
)

print(response)

Appendix C: The Frontend Experience — Making the Ghost Interactive

The backend engine is only half the story. A tribute app needs an interface that makes users feel like they're conversing with the software's spirit. Here's a React-based chat interface with a terminal aesthetic:

// TributeChat.jsx
import React, { useState, useRef, useEffect } from 'react';
import './TributeChat.css';

const TributeChat = ({ softwareName, engineEndpoint }) => {
  const [messages, setMessages] = useState([
    {
      role: 'system',
      content: `You are now connected to the spirit of ${softwareName}. 
                Ask it anything about its architecture, design decisions, or history.`
    }
  ]);
  const [input, setInput] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const messagesEndRef = useRef(null);

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  useEffect(scrollToBottom, [messages]);

  const sendMessage = async () => {
    if (!input.trim() || isLoading) return;

    const userMessage = { role: 'user', content: input };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setIsLoading(true);

    try {
      // Call the tribute engine backend
      const response = await fetch(`${engineEndpoint}/generate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          query: input,
          history: messages.slice(-10) // Send last 10 messages
        })
      });

      const data = await response.json();

      // Typewriter effect for the response
      const aiMessage = { role: 'assistant', content: data.response };
      setMessages(prev => [...prev, aiMessage]);
    } catch (error) {
      setMessages(prev => [
        ...prev,
        { role: 'error', content: 'The spirit seems to be resting. Please try again.' }
      ]);
    } finally {
      setIsLoading(false);
    }
  };

  const handleKeyPress = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      sendMessage();
    }
  };

  return (
    <div className="tribute-container">
      <div className="tribute-header">
        <h1>⌨️ {softwareName}</h1>
        <p className="status-indicator">
          <span className="pulse-dot"></span> Spirit Online
        </p>
      </div>

      <div className="chat-window">
        {messages.map((msg, idx) => (
          <div key={idx} className={`message ${msg.role}`}>
            <div className="message-bubble">
              {msg.content}
            </div>
          </div>
        ))}
        {isLoading && (
          <div className="message assistant">
            <div className="message-bubble typing-indicator">
              <span></span><span></span><span></span>
            </div>
          </div>
        )}
        <div ref={messagesEndRef} />
      </div>

      <div className="input-area">
        <textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyPress={handleKeyPress}
          placeholder="Ask about the codebase..."
          rows={2}
          disabled={isLoading}
        />
        <button onClick={sendMessage} disabled={isLoading || !input.trim()}>
          {isLoading ? '...' : 'Send'}
        </button>
      </div>
    </div>
  );
};

export default TributeChat;

C.1 Terminal Aesthetic CSS

/* TributeChat.css */
.tribute-container {
  max-width: 800px;
  margin: 0 auto;
  font-family: 'Fira Code', 'Courier New', monospace;
  background: #0a0e14;
  color: #b3b1ad;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 0 40px rgba(0, 255, 136, 0.1);
}

.tribute-header {
  background: #1a1e26;
  padding: 16px;
  border-bottom: 1px solid #2e3440;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.tribute-header h1 {
  color: #00ff88;
  font-size: 1.1rem;
  margin: 0;
}

.status-indicator {
  font-size: 0.8rem;
  color: #00ff88;
}

.pulse-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #00ff88;
  margin-right: 6px;
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0% { opacity: 1; }
  50% { opacity: 0.3; }
  100% { opacity: 1; }
}

.chat-window {
  height: 500px;
  overflow-y: auto;
  padding: 20px;
  background: #0d1117;
}

.message {
  margin-bottom: 16px;
  display: flex;
}

.message.user {
  justify-content: flex-end;
}

.message.assistant {
  justify-content: flex-start;
}

.message.error {
  justify-content: center;
}

.message-bubble {
  max-width: 70%;
  padding: 12px 16px;
  border-radius: 8px;
  line-height: 1.5;
  font-size: 0.9rem;
}

.message.user .message-bubble {
  background: #1f6feb;
  color: white;
  border-bottom-right-radius: 2px;
}

.message.assistant .message-bubble {
  background: #21262d;
  border: 1px solid #30363d;
  border-bottom-left-radius: 2px;
}

.message.error .message-bubble {
  background: #da3633;
  color: white;
  font-size: 0.85rem;
}

.typing-indicator span {
  display: inline-block;
  width: 6px;
  height: 6px;
  margin: 0 2px;
  background: #58a6ff;
  border-radius: 50%;
  animation: typing 1.4s infinite;
}

.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }

@keyframes typing {
  0%, 60%, 100% { transform: translateY(0); }
  30% { transform: translateY(-6px); }
}

.input-area {
  display: flex;
  padding: 16px;
  background: #1a1e26;
  border-top: 1px solid #2e3440;
  gap: 12px;
}

.input-area textarea {
  flex: 1;
  background: #0d1117;
  border: 1px solid #30363d;
  border-radius: 6px;
  color: #b3b1ad;
  padding: 12px;
  font-family: inherit;
  resize: none;
  font-size: 0.9rem;
}

.input-area textarea:focus {
  outline: none;
  border-color: #00ff88;
}

.input-area button {
  background: #00ff88;
  border: none;
  border-radius: 6px;
  color: #0a0e14;
  font-weight: bold;
  padding: 0 24px;
  cursor: pointer;
  font-family: inherit;
  font-size: 0.9rem;
}

.input-area button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

Appendix D: Deployment Architecture — Scaling the Ghost

A tribute app must handle bursts of nostalgic traffic. Here's a production deployment architecture using Docker and Kubernetes:

# docker-compose.yml
version: '3.8'

services:
  tribute-api:
    build: ./backend
    ports:
      - "8000:8000"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - REDIS_URL=redis://redis:6379
      - MODEL_NAME=gpt-4o-mini
      - EMBEDDING_MODEL=text-embedding-3-small
    depends_on:
      - redis
      - postgres
    volumes:
      - ./corpus:/app/corpus
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '1.0'
          memory: 2G
        reservations:
          cpus: '0.5'
          memory: 1G

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=tribute
      - POSTGRES_USER=tribute
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  frontend:
    build: ./frontend
    ports:
      - "3000:80"
    depends_on:
      - tribute-api

volumes:
  redis-data:
  postgres-data:

D.1 Caching Layer

To handle high traffic, implement a Redis-based caching layer for frequent queries:

# cache.py
import redis
import json
import hashlib

class TributeCache:
    """Redis-based cache for tribute responses."""

    def __init__(self, redis_url: str, ttl: int = 3600):
        self.client = redis.Redis.from_url(redis_url)
        self.ttl = ttl

    def _make_key(self, query: str, software_name: str, history: list = None) -> str:
        """Create a cache key from query and context."""
        payload = json.dumps({
            'query': query,
            'software': software_name,
            'history': history or []
        })
        return hashlib.sha256(payload.encode()).hexdigest()

    def get(self, query: str, software_name: str, history: list = None):
        """Retrieve cached response."""
        key = self._make_key(query, software_name, history)
        cached = self.client.get(key)
        if cached:
            return json.loads(cached)
        return None

    def set(self, query: str, software_name: str, response: str, history: list = None):
        """Cache a response."""
        key = self._make_key(query, software_name, history)
        self.client.setex(
            key,
            self.ttl,
            json.dumps({'response': response, 'timestamp': time.time()})
        )

Appendix E: The Ethics of Digital Resurrection — A Deeper Framework

The disclaimer at the end of the main article touched on ethics, but this deserves a full treatment. When we build tribute apps for abandoned software, we're making a profound claim: that the work of past engineers deserves to continue speaking.

E.1 The Four Ethical Pillars

1. Attribution Integrity
The tribute app must never claim to be the original software. It's a tribute, not a resurrection. The UI should always display "AI Tribute" alongside the software name. This prevents users from being misled about the nature of the interaction.

2. Developer Consent
Where possible, obtain consent from original developers. This isn't always feasible—projects abandoned for decades may have unreachable authors. In those cases, ensure the tribute is clearly framed as an educational/historical artifact, not an official continuation.

3. Contextual Honesty
The AI must be transparent about what it knows and doesn't know. If the corpus lacks information about a feature, the model should say so rather than hallucinating. This is handled through the retrieval system's confidence thresholds.

4. Legacy Preservation
The primary purpose should be preservation, not replacement. Tribute apps should drive users back to the original source code, documentation, and archives. Every tribute response should include links to the original materials.

E.2 Implementation: The Ethics Module

# ethics.py
from dataclasses import dataclass
from typing import List, Optional

@dataclass
class EthicsConfig:
    """Configuration for ethical guardrails."""
    require_attribution: bool = True
    max_hallucination_risk: float = 0.3
    preserve_original_links: bool = True
    notify_developers: bool = True

class EthicsGuardrail:
    """Enforces ethical constraints on tribute responses."""

    def __init__(self, config: EthicsConfig, original_links: List[str]):
        self.config = config
        self.original_links = original_links

    def validate_response(self, response: str, confidence: float) -> Optional[str]:
        """Validate a response against ethical constraints."""
        # Check hallucination risk
        if confidence < self.config.max_hallucination_risk:
            return (
                "I should be honest with you: I'm not confident about this. "
                "The original documentation doesn't cover this topic in detail. "
                "Here's what I can tell you from the available context..."
            )

        # Add attribution if required
        if self.config.require_attribution:
            attribution = "\n\n---\n*This is an AI tribute. The original software was created by engineers who deserve recognition.*"
            response += attribution

        # Add links to original materials
        if self.config.preserve_original_links and self.original_links:
            links_section = "\n\n**Original Resources:**\n"
            for link in self.original_links[:5]:
                links_section += f"- {link}\n"
            response += links_section

        return response

Appendix F: Performance Optimization — Making the Ghost Fast

Latency kills the magic of a tribute app. Users expect near-instant responses when asking about a software's history. Here's a performance optimization guide:

F.1 Model Selection Strategy

# model_strategy.py
from typing import Dict, Tuple

class ModelRouter:
    """Routes queries to appropriate model based on complexity."""

    def __init__(self, config: Dict):
        self.config = config
        self.model_tiers = {
            'fast': config.get('fast_model', 'gpt-4o-mini'),
            'standard': config.get('standard_model', 'gpt-4o'),
            'complex': config.get('complex_model', 'gpt-4o')
        }

    def route(self, query: str, context_length: int) -> Tuple[str, Dict]:
        """Route query to appropriate model tier."""
        # Heuristic: short queries with small context go to fast model
        if len(query) < 100 and context_length < 5000:
            return self.model_tiers['fast'], {'temperature': 0.7, 'max_tokens': 512}

        # Medium complexity
        if len(query) < 500 and context_length < 20000:
            return self.model_tiers['standard'], {'temperature': 0.8, 'max_tokens': 1024}

        # Complex queries get the full treatment
        return self.model_tiers['complex'], {'temperature': 0.9, 'max_tokens': 2048}

F.2 Streaming Responses

For a better user experience, implement streaming:

# streaming.py
from fastapi import FastAPI, WebSocket
from fastapi.responses import StreamingResponse
import asyncio

app = FastAPI()

@app.post("/generate/stream")
async def generate_stream(request: GenerateRequest):
    """Stream a tribute response token by token."""

    async def generate():
        engine = get_engine()

        # Get the full response
        response = await engine.generate_tribute_async(
            query=request.query,
            software_name=request.software_name
        )

        # Stream it word by word
        for word in response.split():
            yield word + " "
            await asyncio.sleep(0.03)  # Typing speed effect

    return StreamingResponse(
        generate(),
        media_type="text/plain"
    )

Appendix G: Testing the Tribute — Quality Assurance for Digital Ghosts

Testing an AI tribute app is uniquely challenging because the output is probabilistic. Here's a comprehensive testing strategy:

# test_tribute.py
import pytest
import asyncio
from tribute_engine import TributeEngine

class TestTributeEngine:
    """Test suite for the tribute engine."""

    @pytest.fixture
    def engine(self):
        """Create a test engine with mock corpus."""
        corpus = [
            {
                'content': 'def authenticate(user, password):\n    """Authenticate a user."""\n    return check_credentials(user, password)',
                'comments': ['This function handles user authentication'],
                'identifiers': ['authenticate', 'check_credentials']
            },
            {
                'content': 'class SessionManager:\n    """Manages user sessions."""\n    def create_session(self, user):\n        return Session(user)',
                'comments': ['Session management for logged-in users'],
                'identifiers': ['SessionManager', 'create_session']
            }
        ]
        return TributeEngine(TributeConfig(), corpus)

    def test_context_retrieval(self, engine):
        """Test that relevant context is retrieved."""
        context = engine.retrieve_context("How does authentication work?")
        assert len(context) > 0
        assert any('authenticate' in chunk for chunk in context)

    def test_response_generation(self, engine):
        """Test that a response is generated."""
        response = engine.generate_tribute

來源:DEV Community · dev.to