807 lines
32 KiB
PHP
807 lines
32 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\smt\service;
|
||
|
||
use app\smt\model\SmokeAIAdvice;
|
||
use app\smt\model\SmokeAIAdviceUnlock;
|
||
use app\smt\model\SmokeAINextSmoke;
|
||
use app\smt\model\SmokeLog;
|
||
use app\smt\model\SmokeUserProfile;
|
||
use app\smt\model\UserMembership;
|
||
use DateInterval;
|
||
use DateTimeImmutable;
|
||
use think\facade\Db;
|
||
|
||
class SmokeAiService
|
||
{
|
||
public const TYPE_DAILY_ADVICE = 'daily_advice';
|
||
public const TYPE_NEXT_SMOKE = 'next_smoke_time';
|
||
public const TYPE_DAILY_SUMMARY = 'daily_summary';
|
||
|
||
public function getOrGenerateAdvice(array $user, DateTimeImmutable $adviceDate, string $promptVersion = 'v2'): array
|
||
{
|
||
$cached = $this->getCachedByType((int) $user['id'], self::TYPE_DAILY_ADVICE, $adviceDate, $promptVersion);
|
||
if ($cached) {
|
||
return $cached;
|
||
}
|
||
|
||
if (!$this->isAllowed($user, $adviceDate)) {
|
||
throw new \RuntimeException('需要会员或观看广告解锁后才可生成建议', 403);
|
||
}
|
||
|
||
[$snapshot, $snapshotJson] = $this->buildSnapshot((int) $user['id'], $adviceDate);
|
||
[$content, $meta] = $this->generateAdviceText($snapshot);
|
||
|
||
return $this->saveAdviceRecord(
|
||
(int) $user['id'],
|
||
self::TYPE_DAILY_ADVICE,
|
||
$adviceDate,
|
||
$promptVersion,
|
||
$snapshotJson,
|
||
$content,
|
||
$meta
|
||
);
|
||
}
|
||
|
||
public function unlock(array $user, DateTimeImmutable $unlockDate): array
|
||
{
|
||
$unlockDate = Support::dateOnly($unlockDate);
|
||
$now = Support::now();
|
||
$existing = SmokeAIAdviceUnlock::where('uid', (int) $user['id'])
|
||
->where('unlock_date', $unlockDate->format(Support::DATE_LAYOUT))
|
||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||
->find();
|
||
|
||
if (!$existing) {
|
||
$existing = new SmokeAIAdviceUnlock();
|
||
$existing->uid = (int) $user['id'];
|
||
$existing->unlock_date = $unlockDate->format(Support::DATE_LAYOUT);
|
||
$existing->createtime = $now->getTimestamp();
|
||
}
|
||
|
||
$existing->ad_watched_at = $now->format(Support::DATETIME_LAYOUT);
|
||
$existing->updatetime = $now->getTimestamp();
|
||
$existing->save();
|
||
|
||
return [
|
||
'unlocked' => true,
|
||
'date' => $unlockDate->format(Support::DATE_LAYOUT),
|
||
];
|
||
}
|
||
|
||
public function getCachedByType(int $userId, string $type, DateTimeImmutable $date, string $promptVersion): ?array
|
||
{
|
||
$row = SmokeAIAdvice::where('uid', $userId)
|
||
->where('type', $type)
|
||
->where('advice_date', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
|
||
->where('prompt_version', $promptVersion)
|
||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||
->find();
|
||
|
||
if (!$row) {
|
||
return null;
|
||
}
|
||
|
||
return $this->formatAdviceRecord($row->toArray());
|
||
}
|
||
|
||
public function getOrGenerateDailySummary(array $user, DateTimeImmutable $summaryDate, string $promptVersion = 'v1'): array
|
||
{
|
||
$cached = $this->getCachedByType((int) $user['id'], self::TYPE_DAILY_SUMMARY, $summaryDate, $promptVersion);
|
||
if ($cached) {
|
||
return $cached;
|
||
}
|
||
|
||
if (!$this->isAllowed($user, $summaryDate)) {
|
||
throw new \RuntimeException('需要会员或观看广告解锁后才可生成总结', 403);
|
||
}
|
||
|
||
[$snapshot, $snapshotJson] = $this->buildSnapshot((int) $user['id'], $summaryDate, true);
|
||
[$content, $meta] = $this->generateDailySummaryText($snapshot);
|
||
|
||
return $this->saveAdviceRecord(
|
||
(int) $user['id'],
|
||
self::TYPE_DAILY_SUMMARY,
|
||
$summaryDate,
|
||
$promptVersion,
|
||
$snapshotJson,
|
||
$content,
|
||
$meta
|
||
);
|
||
}
|
||
|
||
public function getCachedNextSmoke(array $user, DateTimeImmutable $planDate, string $promptVersion = 'v1'): ?array
|
||
{
|
||
$advice = SmokeAIAdvice::where('uid', (int) $user['id'])
|
||
->where('type', self::TYPE_NEXT_SMOKE)
|
||
->where('advice_date', Support::dateOnly($planDate)->format(Support::DATE_LAYOUT))
|
||
->where('prompt_version', $promptVersion)
|
||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||
->order('id', 'desc')
|
||
->find();
|
||
|
||
if (!$advice) {
|
||
return null;
|
||
}
|
||
|
||
$suggestion = $this->buildNextSuggestionFromCache($advice);
|
||
if ($this->shouldRefreshNextCache($suggestion, $planDate)) {
|
||
return null;
|
||
}
|
||
|
||
return $suggestion;
|
||
}
|
||
|
||
public function getOrGenerateNextSmoke(
|
||
array $user,
|
||
DateTimeImmutable $asOf,
|
||
DateTimeImmutable $planDate,
|
||
string $promptVersion,
|
||
array $defaultSuggestion
|
||
): array {
|
||
$planDate = Support::dateOnly($planDate);
|
||
$cached = $this->getCachedNextSmoke($user, $planDate, $promptVersion);
|
||
if ($cached) {
|
||
return $cached;
|
||
}
|
||
|
||
if (!$this->isAllowed($user, $planDate)) {
|
||
throw new \RuntimeException('需要观看广告解锁后才可生成', 403);
|
||
}
|
||
|
||
$recent3Days = $this->loadRecent3Days((int) $user['id'], $planDate);
|
||
$profile = $this->loadProfileContext((int) $user['id']);
|
||
[$content, $nodes, $notBeforeAt, $suggestedAt, $meta] = $this->generateNextSmokeSuggestion($asOf, $planDate, $defaultSuggestion, $recent3Days, $profile);
|
||
|
||
$advice = SmokeAIAdvice::where('uid', (int) $user['id'])
|
||
->where('type', self::TYPE_NEXT_SMOKE)
|
||
->where('advice_date', $planDate->format(Support::DATE_LAYOUT))
|
||
->where('prompt_version', $promptVersion)
|
||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||
->find();
|
||
|
||
$snapshot = [
|
||
'as_of' => $asOf->format(DATE_ATOM),
|
||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||
'default_suggestion' => $defaultSuggestion,
|
||
'profile' => $profile,
|
||
'recent_3_days' => $recent3Days,
|
||
];
|
||
$snapshotJson = json_encode($snapshot, JSON_UNESCAPED_UNICODE);
|
||
$now = time();
|
||
|
||
if (!$advice) {
|
||
$advice = new SmokeAIAdvice();
|
||
$advice->uid = (int) $user['id'];
|
||
$advice->type = self::TYPE_NEXT_SMOKE;
|
||
$advice->advice_date = $planDate->format(Support::DATE_LAYOUT);
|
||
$advice->prompt_version = $promptVersion;
|
||
$advice->createtime = $now;
|
||
}
|
||
|
||
$advice->provider = $meta['provider'];
|
||
$advice->model = $meta['model'];
|
||
$advice->input_snapshot = $snapshotJson;
|
||
$advice->advice = $content;
|
||
$advice->tokens_in = $meta['tokens_in'];
|
||
$advice->tokens_out = $meta['tokens_out'];
|
||
$advice->updatetime = $now;
|
||
$advice->save();
|
||
|
||
SmokeAINextSmoke::where('ai_advice_id', (int) $advice->id)->delete();
|
||
$this->saveNextNodes((int) $user['id'], (int) $advice->id, $planDate, $notBeforeAt, $suggestedAt, $nodes);
|
||
|
||
return [
|
||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||
'not_before_at' => $notBeforeAt->format(DATE_ATOM),
|
||
'suggested_at' => $suggestedAt->format(DATE_ATOM),
|
||
'time_nodes' => $nodes,
|
||
'advice' => $content,
|
||
'prompt_version' => $promptVersion,
|
||
'model' => $meta['model'],
|
||
'provider' => $meta['provider'],
|
||
];
|
||
}
|
||
|
||
private function saveAdviceRecord(
|
||
int $userId,
|
||
string $type,
|
||
DateTimeImmutable $date,
|
||
string $promptVersion,
|
||
string $snapshotJson,
|
||
string $content,
|
||
array $meta
|
||
): array {
|
||
$now = time();
|
||
$row = new SmokeAIAdvice();
|
||
$row->uid = $userId;
|
||
$row->type = $type;
|
||
$row->advice_date = Support::dateOnly($date)->format(Support::DATE_LAYOUT);
|
||
$row->prompt_version = $promptVersion;
|
||
$row->provider = $meta['provider'];
|
||
$row->model = $meta['model'];
|
||
$row->input_snapshot = $snapshotJson;
|
||
$row->advice = $content;
|
||
$row->tokens_in = $meta['tokens_in'];
|
||
$row->tokens_out = $meta['tokens_out'];
|
||
$row->createtime = $now;
|
||
$row->updatetime = $now;
|
||
$row->save();
|
||
|
||
return $this->formatAdviceRecord($row->toArray());
|
||
}
|
||
|
||
private function formatAdviceRecord(array $row): array
|
||
{
|
||
return [
|
||
'id' => (int) ($row['id'] ?? 0),
|
||
'uid' => (int) ($row['uid'] ?? 0),
|
||
'type' => (string) ($row['type'] ?? ''),
|
||
'date' => (string) ($row['advice_date'] ?? ''),
|
||
'advice_date' => (string) ($row['advice_date'] ?? ''),
|
||
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
|
||
'provider' => (string) ($row['provider'] ?? ''),
|
||
'model' => (string) ($row['model'] ?? ''),
|
||
'advice' => (string) ($row['advice'] ?? ''),
|
||
'content' => (string) ($row['advice'] ?? ''),
|
||
];
|
||
}
|
||
|
||
private function buildSnapshot(int $userId, DateTimeImmutable $date, bool $todayMessage = false): array
|
||
{
|
||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||
->where('uid', $userId)
|
||
->where('smoke_time', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
|
||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) ASC')
|
||
->order('id', 'asc')
|
||
->select()
|
||
->toArray();
|
||
|
||
if (empty($rows)) {
|
||
throw new \RuntimeException($todayMessage ? '今天还没有抽烟记录,无法生成总结' : '该日期没有抽烟记录,无法生成建议', 400);
|
||
}
|
||
|
||
$total = 0;
|
||
$nodes = [];
|
||
foreach ($rows as $row) {
|
||
$total += (int) ($row['num'] ?? 0);
|
||
$eventAt = Support::logEventAt($row);
|
||
$nodes[] = [
|
||
'time' => $eventAt ? $eventAt->format('H:i') : '',
|
||
'num' => (int) ($row['num'] ?? 0),
|
||
'level' => (int) ($row['level'] ?? 1),
|
||
'remark' => (string) ($row['remark'] ?? ''),
|
||
];
|
||
}
|
||
|
||
$profile = $this->loadProfileContext($userId);
|
||
$snapshot = [
|
||
'date' => Support::dateOnly($date)->format(Support::DATE_LAYOUT),
|
||
'total_num' => $total,
|
||
'nodes' => $nodes,
|
||
'profile' => $profile,
|
||
];
|
||
|
||
return [$snapshot, json_encode($snapshot, JSON_UNESCAPED_UNICODE)];
|
||
}
|
||
|
||
private function loadProfileContext(int $userId): ?array
|
||
{
|
||
$profile = SmokeUserProfile::findByUid($userId);
|
||
if (!$profile) {
|
||
return null;
|
||
}
|
||
|
||
$wakeUpTime = trim((string) $profile->wake_up_time);
|
||
$sleepTime = trim((string) $profile->sleep_time);
|
||
$awakeMinutes = 16 * 60;
|
||
try {
|
||
$awakeMinutes = Support::awakeMinutes($wakeUpTime, $sleepTime);
|
||
} catch (\Throwable $e) {
|
||
}
|
||
|
||
return [
|
||
'baseline_cigs_per_day' => (int) $profile->baseline_cigs_per_day,
|
||
'smoking_years' => (float) $profile->smoking_years,
|
||
'pack_price_cent' => (int) $profile->pack_price_cent,
|
||
'smoke_motivations' => Support::jsonArray($profile->smoke_motivations),
|
||
'quit_motivations' => Support::jsonArray($profile->quit_motivations),
|
||
'wake_up_time' => $wakeUpTime,
|
||
'sleep_time' => $sleepTime,
|
||
'awake_minutes' => $awakeMinutes,
|
||
'baseline_interval_minutes' => Support::baselineIntervalMinutes($awakeMinutes, (int) $profile->baseline_cigs_per_day),
|
||
'user_segment' => Support::deriveUserSegment((int) $profile->baseline_cigs_per_day, (float) $profile->smoking_years),
|
||
];
|
||
}
|
||
|
||
private function isAllowed(array $user, DateTimeImmutable $date): bool
|
||
{
|
||
return $this->hasActiveMembership($user) || $this->isUnlocked((int) $user['id'], $date);
|
||
}
|
||
|
||
private function hasActiveMembership(array $user): bool
|
||
{
|
||
$count = UserMembership::where('mini_program_id', (int) $user['mini_program_id'])
|
||
->where('user_id', (int) $user['id'])
|
||
->where('status', 'active')
|
||
->where('ends_at', '>', Support::now()->format(Support::DATETIME_LAYOUT))
|
||
->whereNull('deleted_at')
|
||
->count();
|
||
|
||
return (int) $count > 0;
|
||
}
|
||
|
||
private function isUnlocked(int $userId, DateTimeImmutable $date): bool
|
||
{
|
||
return SmokeAIAdviceUnlock::where('uid', $userId)
|
||
->where('unlock_date', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
|
||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||
->find() !== null;
|
||
}
|
||
|
||
private function generateAdviceText(array $snapshot): array
|
||
{
|
||
$fallback = $this->buildFallbackAdvice($snapshot);
|
||
if (!$this->hasAiConfig()) {
|
||
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||
}
|
||
|
||
$systemPrompt = trim('你是一名专业的戒烟教练。请基于用户昨天的抽烟记录,输出中文建议:先给出 1-3 条模式分析,再给出至少 5 条今天的具体行动建议,最后补充一个 60 秒顶住烟瘾的应对流程。');
|
||
$userPrompt = '用户昨日数据(JSON):' . PHP_EOL . json_encode($snapshot, JSON_UNESCAPED_UNICODE);
|
||
|
||
try {
|
||
$resp = $this->callChat([
|
||
['role' => 'system', 'content' => $systemPrompt],
|
||
['role' => 'user', 'content' => $userPrompt],
|
||
]);
|
||
|
||
return [$resp['content'], $resp];
|
||
} catch (\Throwable $e) {
|
||
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||
}
|
||
}
|
||
|
||
private function generateDailySummaryText(array $snapshot): array
|
||
{
|
||
$fallback = $this->buildFallbackDailySummary($snapshot);
|
||
if (!$this->hasAiConfig()) {
|
||
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||
}
|
||
|
||
$systemPrompt = trim('你是一名专业的戒烟教练。请严格输出 JSON,字段为 summary、highlights、suggestion。内容基于用户当天抽烟数据,语气鼓励。');
|
||
$userPrompt = '用户今日数据(JSON):' . PHP_EOL . json_encode($snapshot, JSON_UNESCAPED_UNICODE);
|
||
|
||
try {
|
||
$resp = $this->callChat([
|
||
['role' => 'system', 'content' => $systemPrompt],
|
||
['role' => 'user', 'content' => $userPrompt],
|
||
]);
|
||
|
||
$jsonText = $this->extractJson($resp['content']);
|
||
if ($jsonText !== '') {
|
||
return [$jsonText, $resp];
|
||
}
|
||
} catch (\Throwable $e) {
|
||
}
|
||
|
||
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||
}
|
||
|
||
private function generateNextSmokeSuggestion(
|
||
DateTimeImmutable $asOf,
|
||
DateTimeImmutable $planDate,
|
||
array $defaultSuggestion,
|
||
array $recent3Days,
|
||
?array $profile
|
||
): array {
|
||
[$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt] = $this->buildFallbackNextSmoke($asOf, $planDate, $defaultSuggestion, $profile);
|
||
if (!$this->hasAiConfig()) {
|
||
return [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||
}
|
||
|
||
$input = [
|
||
'as_of' => $asOf->format(DATE_ATOM),
|
||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||
'default_suggestion' => $defaultSuggestion,
|
||
'profile' => $profile,
|
||
'recent_3_days' => $recent3Days,
|
||
];
|
||
$systemPrompt = trim('你是一名专业的戒烟教练。请严格输出 JSON:not_before_at、suggested_at、time_nodes、advice。所有时间必须属于 plan_date,time_nodes 用 HH:MM。');
|
||
$userPrompt = '输入(JSON):' . PHP_EOL . json_encode($input, JSON_UNESCAPED_UNICODE);
|
||
|
||
try {
|
||
$resp = $this->callChat([
|
||
['role' => 'system', 'content' => $systemPrompt],
|
||
['role' => 'user', 'content' => $userPrompt],
|
||
]);
|
||
$jsonText = $this->extractJson($resp['content']);
|
||
if ($jsonText !== '') {
|
||
$decoded = json_decode($jsonText, true);
|
||
if (is_array($decoded)) {
|
||
$notBeforeAt = $this->parseFlexibleTime((string) ($decoded['not_before_at'] ?? ''), $planDate, $fallbackNotBeforeAt);
|
||
$suggestedAt = $this->parseFlexibleTime((string) ($decoded['suggested_at'] ?? ''), $planDate, $fallbackSuggestedAt);
|
||
if ($suggestedAt < $notBeforeAt) {
|
||
$suggestedAt = $notBeforeAt;
|
||
}
|
||
$nodes = $this->normalizeNodes($decoded['time_nodes'] ?? [], $planDate, $notBeforeAt, $profile);
|
||
|
||
return [
|
||
trim((string) ($decoded['advice'] ?? $fallbackContent)),
|
||
$nodes,
|
||
$notBeforeAt,
|
||
$suggestedAt,
|
||
$resp,
|
||
];
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
}
|
||
|
||
return [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||
}
|
||
|
||
private function buildFallbackAdvice(array $snapshot): string
|
||
{
|
||
$total = (int) ($snapshot['total_num'] ?? 0);
|
||
$nodes = $snapshot['nodes'] ?? [];
|
||
$first = $nodes[0]['time'] ?? '';
|
||
$last = $nodes[count($nodes) - 1]['time'] ?? '';
|
||
$quitMotivation = $snapshot['profile']['quit_motivations'][0] ?? '把状态调整回来';
|
||
|
||
$lines = [
|
||
sprintf('昨天你一共记录了 %d 支烟。%s%s', $total, $first !== '' ? '第一支大约在 ' . $first . ',' : '', $last !== '' ? '最后一支在 ' . $last . '。' : ''),
|
||
'今天先盯住最容易失守的一个时段,把第一根或最顺手的一根至少延后 10 分钟。',
|
||
'每次想抽的时候先喝半杯水,站起来走 30-60 秒,再决定要不要点烟。',
|
||
'如果是饭后或社交触发,提前准备口香糖、无糖饮料或离开吸烟环境 3 分钟。',
|
||
'把今天的目标改成“少一支也算赢”,不要追求一次性完美。',
|
||
sprintf('情绪上来时,重复提醒自己:%s。', $quitMotivation),
|
||
'60 秒应对流程:先深呼吸 4 次,然后喝水 3 口,再拖延 1 分钟,通常烟瘾峰值会先过去。',
|
||
];
|
||
|
||
return implode("\n", $lines);
|
||
}
|
||
|
||
private function buildFallbackDailySummary(array $snapshot): string
|
||
{
|
||
$total = (int) ($snapshot['total_num'] ?? 0);
|
||
$nodes = $snapshot['nodes'] ?? [];
|
||
$times = array_values(array_filter(array_map(static function ($item) {
|
||
return trim((string) ($item['time'] ?? ''));
|
||
}, $nodes)));
|
||
$highlights = [];
|
||
if (!empty($times)) {
|
||
$highlights[] = sprintf('今天首支烟时间大约在 %s,最后一次记录在 %s。', $times[0], $times[count($times) - 1]);
|
||
}
|
||
$highlights[] = sprintf('今日总量为 %d 支,后续可重点观察最容易连续抽烟的时段。', $total);
|
||
$highlights[] = '如果明天只盯住一个节点,优先尝试把最早或最顺手的一支往后拖 10 分钟。';
|
||
|
||
return json_encode([
|
||
'summary' => sprintf('今天共记录 %d 支烟,整体节奏已经被你清楚地记录下来,这本身就是建立改变的第一步。接下来重点不是苛责自己,而是抓住一个最容易多抽的时段做微调。', $total),
|
||
'highlights' => array_slice($highlights, 0, 3),
|
||
'suggestion' => '明天先只做一个动作:把最容易点上的那一支延后 10 分钟,并在这段时间用喝水或起身走动替代。',
|
||
], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
private function buildFallbackNextSmoke(
|
||
DateTimeImmutable $asOf,
|
||
DateTimeImmutable $planDate,
|
||
array $defaultSuggestion,
|
||
?array $profile
|
||
): array {
|
||
$notBeforeAt = !empty($defaultSuggestion['next_smoke_at'])
|
||
? Support::toDateTime((string) $defaultSuggestion['next_smoke_at'])
|
||
: $asOf->add(new DateInterval('PT5M'));
|
||
|
||
if (Support::dateOnly($planDate) > Support::dateOnly($asOf)) {
|
||
$notBeforeAt = Support::dateOnly($planDate)->setTime(7, 0);
|
||
if ($profile && !empty($profile['wake_up_time'])) {
|
||
$wakeMin = Support::parseHHMM((string) $profile['wake_up_time']);
|
||
$notBeforeAt = Support::dateOnly($planDate)->setTime(intdiv($wakeMin, 60), $wakeMin % 60);
|
||
}
|
||
}
|
||
|
||
$suggestedAt = $notBeforeAt;
|
||
$interval = max(20, min(120, (int) ($defaultSuggestion['interval_minutes'] ?? 60)));
|
||
$nodes = [];
|
||
$cursor = $suggestedAt;
|
||
for ($i = 0; $i < 4; $i++) {
|
||
if (Support::dateOnly($cursor) != Support::dateOnly($planDate)) {
|
||
break;
|
||
}
|
||
$nodes[] = $cursor->format('H:i');
|
||
$cursor = $cursor->add(new DateInterval('PT' . max(15, (int) round($interval * 0.7)) . 'M'));
|
||
}
|
||
|
||
$content = sprintf('先按默认节奏走,建议至少等到 %s。如果这段时间烟瘾明显上来,先用喝水、深呼吸或短暂走动顶一轮,再决定要不要抽。', $suggestedAt->format('H:i'));
|
||
|
||
return [$content, array_values(array_unique($nodes)), $notBeforeAt, $suggestedAt];
|
||
}
|
||
|
||
private function loadRecent3Days(int $userId, DateTimeImmutable $planDate): array
|
||
{
|
||
$today = Support::dateOnly();
|
||
$end = $planDate > $today ? $today : $planDate;
|
||
$start = $end->modify('-2 day');
|
||
|
||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||
->where('uid', $userId)
|
||
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
|
||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||
->order('smoke_time', 'asc')
|
||
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) ASC')
|
||
->order('id', 'asc')
|
||
->select()
|
||
->toArray();
|
||
|
||
$grouped = [];
|
||
foreach ($rows as $row) {
|
||
$day = (string) ($row['smoke_time'] ?? '');
|
||
if (!isset($grouped[$day])) {
|
||
$grouped[$day] = ['date' => $day, 'total_num' => 0, 'resisted_count' => 0, 'nodes' => []];
|
||
}
|
||
$isResisted = (int) ($row['level'] ?? 1) === 0 && (int) ($row['num'] ?? 1) === 0;
|
||
if ($isResisted) {
|
||
$grouped[$day]['resisted_count']++;
|
||
} else {
|
||
$grouped[$day]['total_num'] += (int) ($row['num'] ?? 0);
|
||
}
|
||
|
||
$eventAt = Support::logEventAt($row);
|
||
$grouped[$day]['nodes'][] = [
|
||
'time' => $eventAt ? $eventAt->format('H:i') : '',
|
||
'num' => (int) ($row['num'] ?? 0),
|
||
'level' => (int) ($row['level'] ?? 1),
|
||
'is_resisted' => $isResisted,
|
||
'remark' => Support::truncate((string) ($row['remark'] ?? ''), 80),
|
||
];
|
||
}
|
||
|
||
$result = [];
|
||
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->add(new DateInterval('P1D'))) {
|
||
$key = $cursor->format(Support::DATE_LAYOUT);
|
||
$result[] = $grouped[$key] ?? ['date' => $key, 'total_num' => 0, 'resisted_count' => 0, 'nodes' => []];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private function saveNextNodes(int $userId, int $adviceId, DateTimeImmutable $planDate, DateTimeImmutable $notBeforeAt, DateTimeImmutable $suggestedAt, array $nodes): void
|
||
{
|
||
$rows = [
|
||
[
|
||
'uid' => $userId,
|
||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||
'ai_advice_id' => $adviceId,
|
||
'node_type' => 'not_before',
|
||
'node_at' => $notBeforeAt->format(Support::DATETIME_LAYOUT),
|
||
'createtime' => time(),
|
||
'updatetime' => time(),
|
||
],
|
||
[
|
||
'uid' => $userId,
|
||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||
'ai_advice_id' => $adviceId,
|
||
'node_type' => 'suggested',
|
||
'node_at' => $suggestedAt->format(Support::DATETIME_LAYOUT),
|
||
'createtime' => time(),
|
||
'updatetime' => time(),
|
||
],
|
||
];
|
||
|
||
foreach ($nodes as $node) {
|
||
$nodeAt = $this->parseFlexibleTime((string) $node, $planDate, $suggestedAt);
|
||
$rows[] = [
|
||
'uid' => $userId,
|
||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||
'ai_advice_id' => $adviceId,
|
||
'node_type' => 'node',
|
||
'node_at' => $nodeAt->format(Support::DATETIME_LAYOUT),
|
||
'createtime' => time(),
|
||
'updatetime' => time(),
|
||
];
|
||
}
|
||
|
||
Db::connect('mysql')->name('fa_smoke_ai_next_smoke')->insertAll($rows);
|
||
}
|
||
|
||
private function buildNextSuggestionFromCache(SmokeAIAdvice $advice): array
|
||
{
|
||
$rows = SmokeAINextSmoke::where('ai_advice_id', (int) $advice->id)
|
||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||
->order('node_at', 'asc')
|
||
->select()
|
||
->all();
|
||
|
||
$notBeforeAt = '';
|
||
$suggestedAt = '';
|
||
$nodes = [];
|
||
foreach ($rows as $row) {
|
||
if ((string) $row->node_type === 'not_before') {
|
||
$notBeforeAt = Support::formatRfc3339((string) $row->node_at);
|
||
} elseif ((string) $row->node_type === 'suggested') {
|
||
$suggestedAt = Support::formatRfc3339((string) $row->node_at);
|
||
} elseif ((string) $row->node_type === 'node') {
|
||
$nodes[] = Support::formatClock((string) $row->node_at);
|
||
}
|
||
}
|
||
|
||
return [
|
||
'plan_date' => (string) $advice->advice_date,
|
||
'not_before_at' => $notBeforeAt,
|
||
'suggested_at' => $suggestedAt !== '' ? $suggestedAt : $notBeforeAt,
|
||
'time_nodes' => $nodes,
|
||
'advice' => (string) $advice->advice,
|
||
'prompt_version' => (string) $advice->prompt_version,
|
||
'model' => (string) $advice->model,
|
||
'provider' => (string) $advice->provider,
|
||
];
|
||
}
|
||
|
||
private function shouldRefreshNextCache(array $suggestion, DateTimeImmutable $planDate): bool
|
||
{
|
||
$nodes = $suggestion['time_nodes'] ?? [];
|
||
if (empty($nodes)) {
|
||
return true;
|
||
}
|
||
|
||
$suggestedAt = trim((string) ($suggestion['suggested_at'] ?? ''));
|
||
if ($suggestedAt === '') {
|
||
return true;
|
||
}
|
||
|
||
$suggested = $this->parseFlexibleTime($suggestedAt, $planDate, Support::now());
|
||
$today = Support::dateOnly();
|
||
if (Support::dateOnly($planDate) == $today && $suggested <= Support::now()->add(new DateInterval('PT2M'))) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function normalizeNodes(array $nodes, DateTimeImmutable $planDate, DateTimeImmutable $notBeforeAt, ?array $profile): array
|
||
{
|
||
$seen = [];
|
||
$result = [];
|
||
foreach ($nodes as $node) {
|
||
try {
|
||
$nodeAt = $this->parseFlexibleTime((string) $node, $planDate, $notBeforeAt);
|
||
} catch (\Throwable $e) {
|
||
continue;
|
||
}
|
||
if ($nodeAt < $notBeforeAt) {
|
||
continue;
|
||
}
|
||
if ($profile && !empty($profile['wake_up_time']) && !empty($profile['sleep_time'])) {
|
||
$nodeAt = $this->adjustToWakeIfInSleep($nodeAt, (string) $profile['wake_up_time'], (string) $profile['sleep_time']);
|
||
}
|
||
if (Support::dateOnly($nodeAt) != Support::dateOnly($planDate)) {
|
||
continue;
|
||
}
|
||
|
||
$label = $nodeAt->format('H:i');
|
||
if (isset($seen[$label])) {
|
||
continue;
|
||
}
|
||
$seen[$label] = true;
|
||
$result[] = $label;
|
||
if (count($result) >= 6) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private function adjustToWakeIfInSleep(DateTimeImmutable $time, string $wakeUpTime, string $sleepTime): DateTimeImmutable
|
||
{
|
||
$wakeMin = Support::parseHHMM($wakeUpTime);
|
||
$sleepMin = Support::parseHHMM($sleepTime);
|
||
if ($wakeMin === $sleepMin) {
|
||
return $time;
|
||
}
|
||
|
||
$minuteOfDay = ((int) $time->format('H')) * 60 + (int) $time->format('i');
|
||
$inSleep = $wakeMin < $sleepMin
|
||
? ($minuteOfDay < $wakeMin || $minuteOfDay >= $sleepMin)
|
||
: ($minuteOfDay >= $sleepMin && $minuteOfDay < $wakeMin);
|
||
|
||
if (!$inSleep) {
|
||
return $time;
|
||
}
|
||
|
||
$wakeToday = Support::dateOnly($time)->setTime(intdiv($wakeMin, 60), $wakeMin % 60);
|
||
if ($wakeToday <= $time) {
|
||
$wakeToday = $wakeToday->add(new DateInterval('P1D'));
|
||
}
|
||
|
||
return $wakeToday;
|
||
}
|
||
|
||
private function parseFlexibleTime(string $value, DateTimeImmutable $planDate, DateTimeImmutable $fallback): DateTimeImmutable
|
||
{
|
||
$text = trim($value);
|
||
if ($text === '') {
|
||
return $fallback;
|
||
}
|
||
try {
|
||
if (preg_match('/^\d{2}:\d{2}$/', $text)) {
|
||
$minutes = Support::parseHHMM($text);
|
||
return Support::dateOnly($planDate)->setTime(intdiv($minutes, 60), $minutes % 60);
|
||
}
|
||
|
||
return Support::toDateTime($text);
|
||
} catch (\Throwable $e) {
|
||
return $fallback;
|
||
}
|
||
}
|
||
|
||
private function hasAiConfig(): bool
|
||
{
|
||
return trim((string) env('AI_BASE_URL', '')) !== ''
|
||
&& trim((string) env('AI_API_KEY', '')) !== ''
|
||
&& trim((string) env('AI_MODEL', '')) !== '';
|
||
}
|
||
|
||
private function callChat(array $messages): array
|
||
{
|
||
$baseUrl = rtrim((string) env('AI_BASE_URL', ''), '/');
|
||
$apiKey = trim((string) env('AI_API_KEY', ''));
|
||
$model = trim((string) env('AI_MODEL', ''));
|
||
$timeout = max(5, (int) env('AI_TIMEOUT_SECONDS', 15));
|
||
|
||
$payload = json_encode([
|
||
'model' => $model,
|
||
'messages' => $messages,
|
||
'temperature' => 0.7,
|
||
], JSON_UNESCAPED_UNICODE);
|
||
|
||
$ch = curl_init($baseUrl . '/chat/completions');
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||
curl_setopt($ch, CURLOPT_POST, true);
|
||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||
'Content-Type: application/json',
|
||
'Authorization: Bearer ' . $apiKey,
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$error = curl_error($ch);
|
||
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($response === false || $response === '' || $error) {
|
||
throw new \RuntimeException('AI 请求失败', 502);
|
||
}
|
||
if ($statusCode !== 200) {
|
||
throw new \RuntimeException('AI 服务响应异常', 502);
|
||
}
|
||
|
||
$decoded = json_decode($response, true);
|
||
$content = trim((string) ($decoded['choices'][0]['message']['content'] ?? ''));
|
||
if ($content === '') {
|
||
throw new \RuntimeException('AI 返回内容为空', 502);
|
||
}
|
||
|
||
return [
|
||
'content' => $content,
|
||
'provider' => 'openai-compatible',
|
||
'model' => (string) ($decoded['model'] ?? $model),
|
||
'tokens_in' => isset($decoded['usage']['prompt_tokens']) ? (int) $decoded['usage']['prompt_tokens'] : null,
|
||
'tokens_out' => isset($decoded['usage']['completion_tokens']) ? (int) $decoded['usage']['completion_tokens'] : null,
|
||
];
|
||
}
|
||
|
||
private function extractJson(string $value): string
|
||
{
|
||
$start = strpos($value, '{');
|
||
$end = strrpos($value, '}');
|
||
if ($start === false || $end === false || $end <= $start) {
|
||
return '';
|
||
}
|
||
|
||
return substr($value, $start, $end - $start + 1);
|
||
}
|
||
}
|