Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

## 已实现

- **积分与求导**:精确结果、中文规则树、独立验证,答案 / 完整步骤 / 逐步提示;支持定积分、常见反常积分、高阶导数和偏导。
- **积分与求导**:精确结果、中文规则树、独立验证,答案 / 完整步骤 / 逐步提示;支持定积分、常见反常积分、高阶导数、偏导及二元关系的一阶隐函数求导。
- **矩阵与行列式**:表格输入、矩阵运算、秩、线性方程组、行最简形、增广矩阵求逆;展示可重放的初等行变换,与 SymPy 交叉验证。
- **本地题库**:数学一 / 二 / 三;导入 PDF、扫描 PDF、DOCX,保留原文件和可用截图,人工校对、拆分、合并及裁切。
- **做题工作台**:题目与草稿动态分配空间,可以拖动调整;计算器可收起、全屏或浮动,结果与完整步骤可插入笔记。
Expand Down Expand Up @@ -78,7 +78,7 @@ bash start.sh

试卷自动拆题是候选结果,需要人工校对。扫描件 OCR 需要本地 Tesseract;DOCX 保留原版式截图需要 LibreOffice,否则仅提取可识别的内容。OCR 公式送入计算器前必须确认。

原始触控板书写仅支持 Windows 上可读取的 Precision Touchpad,建议使用外接鼠标。不同设备需要实际试写确认,普通鼠标画布可以直接使用。笔迹暂不自动识别成公式,隐函数求导、自动批改和排行榜尚未实现。
原始触控板书写仅支持 Windows 上可读取的 Precision Touchpad,建议使用外接鼠标。不同设备需要实际试写确认,普通鼠标画布可以直接使用。笔迹暂不自动识别成公式,自动批改和排行榜尚未实现。隐函数模式目前只支持二元关系的一阶求导,不支持高阶隐导数、额外参数或自动分支分析。

## 数据与开发

Expand Down Expand Up @@ -131,3 +131,20 @@ npm run build
配套手机测试版下载、黑屏操作的实际含义及许可说明,请阅读 [手机黑屏草稿使用说明](docs/手机黑屏草稿使用说明.md)。APK 是纯黑画面和最低窗口亮度下的触摸操作,不是系统锁屏后手写。版本变化见 [更新记录](CHANGELOG.md)。

![小窗口优先显示题目与草稿](docs/screenshots/compact-practice.png)


### 一阶隐函数求导

在「求导」中选择「一阶隐函数求导」,输入 `x^2+y^2=1` 或 `sin(x*y)=x+y`,指定不同的自变量和因变量。也支持公式输入;省略等号时将表达式作为 F=0。可同时填写两个点坐标,检查该点是否满足方程及原始表达式的实数定义域。

过程展示移项、将 y 看成 y(x) 后的链式法则、整理含 y′ 的项、除以 Fy 和化简。最终答案同时通过 SymPy `idiff` 与全微分关系回代检查。原关系须在所考察点邻域内满足 C¹,且 Fy≠0;原方程中的分母、对数、根式等定义域限制不会因化简而取消。本功能不自动证明邻域光滑性、存在性或唯一分支。

Fy=0 时不直接断言不能求导:Fx≠0 时仅在 C¹ 条件下报告无有限导数;Fx=0 或无法确定时报告当前方法无法判定。例如 `y^3=x^3` 在原点仍可能可导,需进一步分析。`verified` 表示条件下的符号结果已验证;`steps_complete=false` 可以只表示中文规则未完整展开,不意味着导数不存在。独立验证失败时隐藏最终导数,返回 `unverified`。

接口复用 `POST /api/math/differentiate`:

```json
{"expression":"x^2+y^2=1","mode":"implicit","variable":"x","dependent_variable":"y","point":{"x":"3/5","y":"4/5"}}
```

`point` 可省略。隐函数模式只接受 `order=1`,不接受 `variables`;额外符号参数、连等式和非数学输入会被拒绝。普通模式默认 `mode=explicit`,现有普通求导、混合偏导、积分和历史 API 保持兼容;隐函数的模式、点坐标及步骤也保存到原计算历史中。
3 changes: 3 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class IntegrateInput(CalculusInput):
lower:str|None=Field(default=None,max_length=100)
upper:str|None=Field(default=None,max_length=100)
class DifferentiateInput(CalculusInput):
mode:Literal['explicit','implicit']='explicit'
dependent_variable:str=Field(default='y',min_length=1,max_length=1)
point:dict[str,str]|None=Field(default=None,max_length=2)
order:int=Field(default=1,ge=1,le=8)
variables:list[str]|None=Field(default=None,max_length=8)
class MatrixInput(Input):
Expand Down
11 changes: 9 additions & 2 deletions backend/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,16 @@ def integrate_job(req):
out['steps']=steps+bounded_steps; return out


def derivative_trace(f,x,tr,depth=0):
def derivative_trace(f,x,tr,depth=0,dependent=None):
actual=s.diff(f,x); kids=[]; formula=None
if depth>18:
tr.complete=False
return tr.step('symbolic_derivative',s.Derivative(f,x,evaluate=False),actual,'表达式过深,仅给出符号求导结果。',kind='derivative',verified=True)
def sub(g): return derivative_trace(g,x,tr,depth+1)
def sub(g): return derivative_trace(g,x,tr,depth+1,dependent)
if not f.has(x): rule='constant'; rhs=s.S.Zero; explanation='对当前变量,常数和其他独立变量的导数为零。'
elif f==x: rule='identity'; rhs=s.S.One; explanation='变量对自身的导数为 1。'
elif dependent is not None and f==dependent:
rule='dependent_variable'; rhs=actual; explanation='将因变量视为自变量的函数,其导数保留为一阶导数,不能按独立变量处理为零。'
elif f.is_Add:
rule='sum'; kids=[sub(g) for g in f.args]; rhs=sum(s.diff(g,x) for g in f.args); explanation='按求导的线性性质逐项求导。'
elif f.is_Mul:
Expand Down Expand Up @@ -239,6 +241,11 @@ def sub(g): return derivative_trace(g,x,tr,depth+1)


def differentiate_job(req):
if req.get('mode','explicit')=='implicit':
from .implicit import implicit_differentiate_job
return implicit_differentiate_job(req)
if req.get('mode','explicit')!='explicit': raise MathInputError('不支持的求导模式。')
if req.get('point') is not None: raise MathInputError('点值检查仅用于隐函数模式。')
tr=Trace(); f=s.simplify(parse(req['expression'])); x=variable(req.get('variable','x'))
if f.has(s.zoo,s.nan,s.oo,-s.oo):raise MathInputError('待求导函数不能包含无穷大或未定义常量。')
order=req.get('order',1)
Expand Down
114 changes: 114 additions & 0 deletions backend/implicit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""First-order implicit differentiation without solving for a branch of y."""
import sympy as s
from .parser import parse, parse_equation, variable, MathInputError
from .engine import Trace, response, equal, derivative_trace, all_verified


def implicit_differentiate_job(req):
if type(req.get('order',1)) is not int or req.get('order',1)!=1 or req.get('variables') is not None:
raise MathInputError('隐函数模式仅支持一阶求导,不能同时指定混合偏导变量序列。')
x=variable(req.get('variable','x')); y=variable(req.get('dependent_variable','y'))
if x==y: raise MathInputError('自变量和因变量必须不同。')
lhs,rhs=parse_equation(req['expression'])
if (lhs.free_symbols|rhs.free_symbols)-{x,y}:
raise MathInputError('当前隐函数模式仅支持所选的两个变量,不支持额外参数。')
if any(v.has(s.zoo,s.nan,s.oo,-s.oo) for v in (lhs,rhs)):
raise MathInputError('方程不能包含无穷大或未定义常量。')
raw=s.Add(lhs,-rhs,evaluate=False); f=s.simplify(raw)
if f.has(s.zoo,s.nan,s.oo,-s.oo) or any(
not node.free_symbols and (node.is_real is False or node.is_finite is False)
for node in s.preorder_traversal(raw)):
raise MathInputError('方程包含未定义或非实数常量,请检查实数定义域。')
fx=s.simplify(s.diff(f,x)); fy=s.simplify(s.diff(f,y))
tr=Trace(); steps=[]; equation=s.Eq(lhs,rhs,evaluate=False)
assumptions=['所有变量为实数;须满足原方程及原始表达式的实数定义域(化简不扩大定义域)。',
'在所考察点的邻域内,原关系 F 的一阶偏导数须连续(C¹)。',
s.latex(s.Eq(f,0,evaluate=False)),s.latex(fy)+r'\ne0']
tr.warnings.append('Fy≠0 是此公式适用的充分条件,并非可微的必要条件;Fy=0 时不能直接用该公式判断导数不存在。')
if raw.has(s.Abs):
tr.warnings.append('含绝对值:须另行检查折点;符号结果仅在原关系满足 C¹ 条件的区域使用。')

# Keep the unsimplified operands for point-domain checks, including removable holes.
point=req.get('point'); substitutions=None
if point is not None:
if not isinstance(point,dict) or set(point)!={str(x),str(y)}:
raise MathInputError('点坐标必须恰好包含自变量和因变量。')
substitutions={v:s.simplify(parse(point[str(v)])) for v in (x,y)}
if any(v.free_symbols or v.is_real is not True or v.is_finite is not True for v in substitutions.values()):
raise MathInputError('点坐标必须是明确的有限实数。')
for node in s.preorder_traversal(raw):
value=node.subs(substitutions,simultaneous=True)
if value.is_real is not True or value.is_finite is not True:
raise MathInputError('该点不在原始表达式可确认的实数定义域内。')
if not equal(raw.subs(substitutions,simultaneous=True),0):
raise MathInputError('给定点不满足原方程。')
assumptions.append('点坐标:'+', '.join(f'{v}={value}' for v,value in substitutions.items()))

def relation(rule,before,after,explanation,ok,**extra):
return tr.step(rule,before,after,explanation,kind='implicit_relation',verified=ok,**extra)
steps.append(relation('implicit_rearrange',equation,s.Eq(f,0,evaluate=False),
'将右侧移到左侧,记 F=左侧−右侧;保留原方程的定义域限制。',equal(raw,f)))
yfun=s.Function(str(y))(x); lifted=raw.subs(y,yfun)
chain=derivative_trace(lifted,x,tr,dependent=yfun)
derivative=s.diff(lifted,x); p=s.Symbol(str(y)+"'")
total=derivative.subs(s.diff(yfun,x),p).subs(yfun,y)
collected=fx+fy*p
steps.append(relation('implicit_chain',s.Eq(s.Derivative(lifted,x,evaluate=False),0,evaluate=False),
s.Eq(total,0,evaluate=False),'把因变量看成函数,逐项使用乘积法则和链式法则。',
equal(total,collected),children=[chain],formula_latex=r'\frac{dF}{d'+str(x)+r'}=F_'+str(x)+r'+F_'+str(y)+r'\frac{d'+str(y)+r'}{d'+str(x)+r'}=0'))
steps.append(relation('implicit_collect',s.Eq(total,0,evaluate=False),
s.Eq(fy*p,-fx,evaluate=False),'合并含因变量导数的项,再将其余项移到右侧。',equal(total,collected)))

at_fy=fy if substitutions is None else s.simplify(fy.subs(substitutions,simultaneous=True))
if substitutions is not None:
partials=[v.subs(substitutions,simultaneous=True) for v in (fx,fy)]
if any(v.is_real is not True or v.is_finite is not True for v in partials):
raise MathInputError('该点的偏导数无法确认为有限实数,当前 C¹ 隐函数公式不适用;不能据此断言导数不存在。')
if at_fy.is_zero is True:
tr.complete=False
assumptions.remove(s.latex(fy)+r'\ne0')
assumptions.append('此处 Fy=0,不满足导数公式的除法条件;只分析一阶关系。')
at_fx=fx if substitutions is None else s.simplify(fx.subs(substitutions,simultaneous=True))
status='no_finite_derivative' if at_fx.is_zero is False else 'undetermined'
tr.warnings.append('Fy=0 且 Fx≠0:在 C¹ 条件下不存在有限的因变量导数。' if status=='no_finite_derivative'
else 'Fy=0 且一阶关系退化:当前方法无法判定,需分析分支或使用更高阶信息;不代表不能求导。')
out=response('differentiate',equation,None,'一阶隐函数求导 · 特殊点分析',tr,False,assumptions,
status=status,mode='implicit')
out.update(steps=steps,result_latex=r'\text{'+('不存在有限导数(须满足 C¹)' if status=='no_finite_derivative' else '当前方法无法判定')+'}')
return out

result=s.simplify(-fx/fy)
# Independent route: SymPy's implicit differentiation, plus total-derivative residual.
try:
reference=s.idiff(f,y,x)
verified=equal(result,reference) and equal(total.subs(p,result),0)
except (ValueError,NotImplementedError,TypeError):
reference=None; verified=False
if result.has(s.Derivative,s.zoo,s.nan,s.oo,-s.oo):
verified=False
if not verified:
tr.complete=False; tr.warnings.append('未能通过 SymPy 独立验证,不展示未经验证的导数。')
out=response('differentiate',equation,None,'一阶隐函数求导 · 验证未完成',tr,False,assumptions,status='unverified',mode='implicit')
out.update(steps=steps,result_latex=r'\text{结果验证未完成}'); return out
quotient=-fx/fy
steps.append(relation('implicit_solve',s.Eq(fy*p,-fx,evaluate=False),s.Eq(p,quotient,evaluate=False),
'在所列条件下除以 F 的因变量偏导数,得到导数公式。',equal(fy*quotient,-fx)))
steps.append(tr.step('implicit_simplify',quotient,result,'化简导数,原方程的定义域与非零条件仍然保留。'))
check=relation('implicit_verification',result,reference,
'独立调用 SymPy idiff,并把导数代回全微分关系,残差应为零。',verified)
check['verification'].update(expression=str(f),variable=str(x),dependent_variable=str(y),
residual=str(s.simplify(total.subs(p,result))),sympy_result=str(reference))
steps.append(check)
if substitutions is not None:
value=s.simplify(result.subs(substitutions,simultaneous=True))
if value.is_real is not True or value.is_finite is not True:
raise MathInputError('无法确认该点的导数为有限实数,请检查光滑性和定义域。')
steps.append(tr.step('implicit_point',result,value,'代入点坐标;仍须检查该点邻域内的 C¹ 条件。',
kind='point_substitution',verified=equal(result.subs(substitutions,simultaneous=True),value)))
result=value
if not all_verified(steps):
steps=[]; tr.complete=False; tr.warnings.append('部分推导未通过检查,已隐藏;最终结果已通过独立验证。')
out=response('differentiate',equation,result,'一阶隐函数求导 · 链式法则 + SymPy idiff 独立验证',tr,True,
assumptions,status='conditional',mode='implicit')
out.update(steps=steps,result_latex=r'\frac{d'+str(y)+r'}{d'+str(x)+r'}='+s.latex(result))
return out
15 changes: 12 additions & 3 deletions backend/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@ class MathInputError(ValueError):
CONSTANTS = {'pi': s.pi, 'e': s.E, 'E': s.E, 'oo': s.oo}

def variable(name):
if not re.fullmatch('[a-df-z]', name):
if not isinstance(name,str) or not re.fullmatch('[a-df-z]', name):
raise MathInputError('变量请使用单个英文字母(e 为自然常数)。')
return s.Symbol(name, real=True)

def parse_equation(text):
"""Only this entry point accepts '='; both sides retain the closed grammar."""
if not isinstance(text,str) or not text.strip() or len(text)>600:
raise MathInputError('方程不能为空且不能超过 600 字符。')
if text.count('=')>1:
raise MathInputError('只支持一个等号的方程,不支持连等式。')
sides=text.split('=') if '=' in text else [text,'0']
return parse(sides[0],evaluate_functions=False),parse(sides[1],evaluate_functions=False)

class LatexReader:
def __init__(self, text):
self.text, self.i = text, 0
Expand Down Expand Up @@ -61,7 +70,7 @@ def read(self, end=None):

TOKEN=re.compile(r'\s*(?:(\d+(?:\.\d+)?|\.\d+)|([A-Za-z]+)|(\*\*|[+\-*/^(),|]))')

def parse(text):
def parse(text,*,evaluate_functions=True):
if not isinstance(text,str) or not text.strip() or len(text)>600:
raise MathInputError('表达式不能为空且不能超过 600 字符。')
text=text.strip().replace('−','-').replace('×','*').replace('÷','/').replace('π','pi').replace('∞','oo')
Expand Down Expand Up @@ -101,7 +110,7 @@ def expr(min_bp=0):
if tokens[i]!=')': raise MathInputError('函数仅支持一个参数且括号必须完整。')
i+=1
else: arg=expr(25)
lhs=FUNCTIONS[tok](arg)
lhs=FUNCTIONS[tok](arg,evaluate=evaluate_functions)
if exponent is not None: lhs=s.Pow(lhs,exponent,evaluate=False)
elif tok in CONSTANTS: lhs=CONSTANTS[tok]
elif re.fullmatch('[a-df-z]',tok): lhs=variable(tok)
Expand Down
49 changes: 49 additions & 0 deletions tests/browser/implicit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {test,expect} from '@playwright/test';

test('implicit chain rule, point and history restore',async({page})=>{
await page.goto('/');
await page.getByRole('button',{name:'求导',exact:true}).click();
await page.getByLabel('求导模式').selectOption('implicit');
await expect(page.getByLabel('导数阶数')).toHaveCount(0);
await expect(page.getByLabel('混合偏导')).toHaveCount(0);
await page.getByRole('button',{name:'文本表达式',exact:true}).click();
await page.getByLabel('文本表达式',{exact:true}).fill('x^2+y^2=1');
await page.getByLabel('自变量点坐标').fill('3/5');
await page.getByLabel('因变量点坐标').fill('4/5');
await page.getByRole('button',{name:'开始计算',exact:true}).click();
await expect(page.locator('.verified-badge')).toContainText('验证通过');
await expect(page.locator('.step summary').filter({hasText:'隐函数链式法则'})).toBeVisible();
await expect(page.locator('.step summary').filter({hasText:'整理含导数的项'})).toBeVisible();
await expect(page.locator('.conditions')).toContainText('C¹');
await page.getByRole('button',{name:'计算历史',exact:true}).click();
await page.locator('.history-item').first().click();
await expect(page.getByLabel('求导模式')).toHaveValue('implicit');
await expect(page.getByLabel('自变量点坐标')).toHaveValue('3/5');
await expect(page.getByLabel('因变量点坐标')).toHaveValue('4/5');
await page.getByLabel('自变量点坐标').fill('0');
await page.getByLabel('因变量点坐标').fill('0');
await page.getByLabel('文本表达式',{exact:true}).fill('y^3=x^3');
await page.getByRole('button',{name:'开始计算',exact:true}).click();
await expect(page.locator('.warnings')).toContainText('当前方法无法判定');
await expect(page.locator('.answer-box')).toContainText('当前方法无法判定');
await page.getByLabel('求导模式').selectOption('explicit');
await expect(page.getByLabel('导数阶数')).toBeVisible();
await page.getByLabel('文本表达式',{exact:true}).fill('sin(x^2)');
await page.getByRole('button',{name:'开始计算',exact:true}).click();
await expect(page.locator('.answer-box')).toContainText('cos');
await expect(page.locator('.verified-badge')).toContainText('验证通过');
});

test('formula input accepts an implicit equation',async({page})=>{
await page.goto('/');
await page.getByRole('button',{name:'求导',exact:true}).click();
await page.getByLabel('求导模式').selectOption('implicit');
const field=page.locator('math-field');
await field.evaluate(el=>{
(el as HTMLElement & {value:string}).value='x^2+y^2=1';
el.dispatchEvent(new Event('input',{bubbles:true}));
});
await page.getByRole('button',{name:'开始计算',exact:true}).click();
await expect(page.locator('.verified-badge')).toContainText('验证通过');
await expect(page.locator('.step summary').filter({hasText:'隐函数独立验证'})).toBeVisible();
});
Loading
Loading