feat: Add experiments framework and novelty-driven agent loop

- Add complete experiments directory with pilot study infrastructure
  - 5 experimental conditions (direct, expert-only, attribute-only, full-pipeline, random-perspective)
  - Human assessment tool with React frontend and FastAPI backend
  - AUT flexibility analysis with jump signal detection
  - Result visualization and metrics computation

- Add novelty-driven agent loop module (experiments/novelty_loop/)
  - NoveltyDrivenTaskAgent with expert perspective perturbation
  - Three termination strategies: breakthrough, exhaust, coverage
  - Interactive CLI demo with colored output
  - Embedding-based novelty scoring

- Add DDC knowledge domain classification data (en/zh)
- Add CLAUDE.md project documentation
- Update research report with experiment findings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 10:16:21 +08:00
parent 26a56a2a07
commit 43c025e060
81 changed files with 18766 additions and 2 deletions

View File

@@ -0,0 +1,178 @@
"""
Condition 5: Random-Perspective Control
Uses random words as "perspectives" instead of domain experts.
Tests whether the benefit from expert perspectives comes from
domain knowledge or simply from any perspective shift.
"""
import sys
import json
import random
from pathlib import Path
# Add backend to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "backend"))
from typing import List, Dict, Any
from app.services.llm_service import ollama_provider, extract_json_from_response
from experiments.config import (
MODEL, TEMPERATURE, EXPERT_COUNT, IDEAS_PER_EXPERT,
PROMPT_LANGUAGE, RANDOM_SEED, DATA_DIR
)
def load_random_words() -> List[str]:
"""Load the random word pool from data file."""
words_file = DATA_DIR / "random_words.json"
with open(words_file, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("words", [])
def get_random_perspective_prompt(
query: str,
perspective_word: str,
idea_count: int,
lang: str = "en"
) -> str:
"""Generate prompt for random-perspective idea generation."""
if lang == "en":
return f"""/no_think
Generate {idea_count} creative and innovative ideas for "{query}" inspired by the concept of "{perspective_word}".
Requirements:
1. Each idea should draw inspiration from "{perspective_word}" - its qualities, characteristics, or associations
2. Think about how concepts related to "{perspective_word}" could improve or reimagine "{query}"
3. Ideas should be specific and actionable (15-30 words each)
4. Be creative in connecting "{perspective_word}" to "{query}"
Return JSON only:
{{"ideas": ["idea 1", "idea 2", "idea 3", ...]}}
Generate exactly {idea_count} ideas inspired by "{perspective_word}"."""
else:
return f"""/no_think
為「{query}」生成 {idea_count} 個創意點子,靈感來自「{perspective_word}」這個概念。
要求:
1. 每個點子要從「{perspective_word}」獲得靈感——它的特質、特徵或聯想
2. 思考與「{perspective_word}」相關的概念如何改進或重新想像「{query}
3. 點子要具體可行(每個 15-30 字)
4. 創意地連接「{perspective_word}」和「{query}
只回傳 JSON
{{"ideas": ["點子1", "點子2", "點子3", ...]}}
生成正好 {idea_count} 個受「{perspective_word}」啟發的點子。"""
async def generate_ideas(
query: str,
model: str = None,
temperature: float = None,
word_count: int = None,
ideas_per_word: int = None,
lang: str = None,
seed: int = None
) -> Dict[str, Any]:
"""
Generate ideas using random word perspectives (C5 control).
Args:
query: The object/concept to generate ideas for
model: LLM model to use
temperature: Generation temperature
word_count: Number of random words to use (matches expert count)
ideas_per_word: Ideas to generate per word
lang: Language for prompts
seed: Random seed for reproducibility
Returns:
Dict with ideas and metadata
"""
model = model or MODEL
temperature = temperature or TEMPERATURE
word_count = word_count or EXPERT_COUNT
ideas_per_word = ideas_per_word or IDEAS_PER_EXPERT
lang = lang or PROMPT_LANGUAGE
seed = seed or RANDOM_SEED
# Load word pool and sample random words
word_pool = load_random_words()
# Use seeded random for reproducibility
# Create a unique seed per query to get different words for different queries
# but same words for same query across runs
query_seed = seed + hash(query) % 10000
rng = random.Random(query_seed)
selected_words = rng.sample(word_pool, min(word_count, len(word_pool)))
all_ideas = []
word_details = []
for word in selected_words:
prompt = get_random_perspective_prompt(
query=query,
perspective_word=word,
idea_count=ideas_per_word,
lang=lang
)
response = await ollama_provider.generate(
prompt=prompt,
model=model,
temperature=temperature
)
result = extract_json_from_response(response)
ideas = result.get("ideas", [])
# Tag ideas with perspective word source
for idea in ideas:
all_ideas.append({
"idea": idea,
"perspective_word": word
})
word_details.append({
"word": word,
"ideas_generated": len(ideas)
})
return {
"condition": "c5_random_perspective",
"query": query,
"ideas": [item["idea"] for item in all_ideas],
"ideas_with_source": all_ideas,
"idea_count": len(all_ideas),
"metadata": {
"model": model,
"temperature": temperature,
"prompt_language": lang,
"word_count": word_count,
"ideas_per_word": ideas_per_word,
"random_seed": seed,
"query_seed": query_seed,
"selected_words": selected_words,
"word_details": word_details,
"word_pool_size": len(word_pool),
"mechanism": "random_perspective_control"
}
}
# For testing
if __name__ == "__main__":
import asyncio
async def test():
result = await generate_ideas("Chair")
print(f"Generated {result['idea_count']} ideas from {len(result['metadata']['selected_words'])} random words:")
print(f" Words used: {', '.join(result['metadata']['selected_words'])}")
print(f" Seed: {result['metadata']['random_seed']}, Query seed: {result['metadata']['query_seed']}")
print("\nSample ideas:")
for i, item in enumerate(result['ideas_with_source'][:5], 1):
print(f" {i}. [{item['perspective_word']}] {item['idea']}")
asyncio.run(test())